merge feat/plugins

This commit is contained in:
Joel 2024-11-08 13:57:34 +08:00
commit 5d7c527702
133 changed files with 3602 additions and 1774 deletions

View File

@ -54,10 +54,12 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs):
if response.status_code not in STATUS_FORCELIST:
return response
else:
logging.warning(f"Received status code {response.status_code} for URL {url} which is in the force list")
logging.warning(
f"Received status code {response.status_code} for URL {url} which is in the force list")
except httpx.RequestError as e:
logging.warning(f"Request to URL {url} failed on attempt {retries + 1}: {e}")
logging.warning(
f"Request to URL {url} failed on attempt {retries + 1}: {e}")
retries += 1
if retries <= max_retries:

View File

@ -20,7 +20,8 @@ from extensions.ext_redis import redis_client
from models.dataset import Dataset
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logging.basicConfig(level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s")
logging.getLogger("lindorm").setLevel(logging.WARN)
@ -76,7 +77,8 @@ class LindormVectorStore(BaseVector):
@retry(stop=stop_after_attempt(3), wait=wait_fixed(60))
def __fetch_existing_ids(batch_ids: list[str]) -> set[str]:
try:
existing_docs = self._client.mget(index=self._collection_name, body={"ids": batch_ids}, _source=False)
existing_docs = self._client.mget(index=self._collection_name, body={
"ids": batch_ids}, _source=False)
return {doc["_id"] for doc in existing_docs["docs"] if doc["found"]}
except Exception as e:
logger.exception(f"Error fetching batch {batch_ids}: {e}")
@ -88,7 +90,8 @@ class LindormVectorStore(BaseVector):
existing_docs = self._client.mget(
body={
"docs": [
{"_index": self._collection_name, "_id": id, "routing": routing}
{"_index": self._collection_name,
"_id": id, "routing": routing}
for id, routing in zip(batch_ids, route_ids)
]
},
@ -112,12 +115,13 @@ class LindormVectorStore(BaseVector):
def batch(iterable, n):
length = len(iterable)
for idx in range(0, length, n):
yield iterable[idx : min(idx + n, length)]
yield iterable[idx: min(idx + n, length)]
for ids_batch, texts_batch, metadatas_batch in zip(
batch(ids, bulk_size),
batch(texts, bulk_size),
batch(metadatas, bulk_size) if metadatas is not None else batch([None] * len(ids), bulk_size),
batch(metadatas, bulk_size) if metadatas is not None else batch(
[None] * len(ids), bulk_size),
):
existing_ids_set = __fetch_existing_ids(ids_batch)
for text, metadata, doc_id in zip(texts_batch, metadatas_batch, ids_batch):
@ -139,7 +143,8 @@ class LindormVectorStore(BaseVector):
"_id": uuids[i],
"_source": {
Field.CONTENT_KEY.value: documents[i].page_content,
Field.VECTOR.value: embeddings[i], # Make sure you pass an array here
# Make sure you pass an array here
Field.VECTOR.value: embeddings[i],
Field.METADATA_KEY.value: documents[i].metadata,
},
}
@ -148,7 +153,8 @@ class LindormVectorStore(BaseVector):
self.refresh()
def get_ids_by_metadata_field(self, key: str, value: str):
query = {"query": {"term": {f"{Field.METADATA_KEY.value}.{key}.keyword": value}}}
query = {
"query": {"term": {f"{Field.METADATA_KEY.value}.{key}.keyword": value}}}
response = self._client.search(index=self._collection_name, body=query)
if response["hits"]["hits"]:
return [hit["_id"] for hit in response["hits"]["hits"]]
@ -157,7 +163,8 @@ class LindormVectorStore(BaseVector):
def delete_by_metadata_field(self, key: str, value: str):
query_str = {"query": {"match": {f"metadata.{key}": f"{value}"}}}
results = self._client.search(index=self._collection_name, body=query_str)
results = self._client.search(
index=self._collection_name, body=query_str)
ids = [hit["_id"] for hit in results["hits"]["hits"]]
if ids:
self.delete_by_ids(ids)
@ -167,15 +174,18 @@ class LindormVectorStore(BaseVector):
if self._client.exists(index=self._collection_name, id=id):
self._client.delete(index=self._collection_name, id=id)
else:
logger.warning(f"DELETE BY ID: ID {id} does not exist in the index.")
logger.warning(
f"DELETE BY ID: ID {id} does not exist in the index.")
def delete(self) -> None:
try:
if self._client.indices.exists(index=self._collection_name):
self._client.indices.delete(index=self._collection_name, params={"timeout": 60})
self._client.indices.delete(
index=self._collection_name, params={"timeout": 60})
logger.info("Delete index success")
else:
logger.warning(f"Index '{self._collection_name}' does not exist. No deletion performed.")
logger.warning(
f"Index '{self._collection_name}' does not exist. No deletion performed.")
except Exception as e:
logger.exception(f"Error occurred while deleting the index: {e}")
raise e
@ -197,9 +207,11 @@ class LindormVectorStore(BaseVector):
raise ValueError("All elements in query_vector should be floats")
top_k = kwargs.get("top_k", 10)
query = default_vector_search_query(query_vector=query_vector, k=top_k, **kwargs)
query = default_vector_search_query(
query_vector=query_vector, k=top_k, **kwargs)
try:
response = self._client.search(index=self._collection_name, body=query)
response = self._client.search(
index=self._collection_name, body=query)
except Exception as e:
logger.exception(f"Error executing search: {e}")
raise
@ -244,7 +256,8 @@ class LindormVectorStore(BaseVector):
filters=filters,
routing=routing,
)
response = self._client.search(index=self._collection_name, body=full_text_query)
response = self._client.search(
index=self._collection_name, body=full_text_query)
docs = []
for hit in response["hits"]["hits"]:
docs.append(
@ -262,7 +275,8 @@ class LindormVectorStore(BaseVector):
with redis_client.lock(lock_name, timeout=20):
collection_exist_cache_key = f"vector_indexing_{self._collection_name}"
if redis_client.get(collection_exist_cache_key):
logger.info(f"Collection {self._collection_name} already exists.")
logger.info(
f"Collection {self._collection_name} already exists.")
return
if self._client.indices.exists(index=self._collection_name):
logger.info("{self._collection_name.lower()} already exists.")
@ -281,10 +295,13 @@ class LindormVectorStore(BaseVector):
hnsw_ef_construction = kwargs.pop("hnsw_ef_construction", 500)
ivfpq_m = kwargs.pop("ivfpq_m", dimension)
nlist = kwargs.pop("nlist", 1000)
centroids_use_hnsw = kwargs.pop("centroids_use_hnsw", True if nlist >= 5000 else False)
centroids_use_hnsw = kwargs.pop(
"centroids_use_hnsw", True if nlist >= 5000 else False)
centroids_hnsw_m = kwargs.pop("centroids_hnsw_m", 24)
centroids_hnsw_ef_construct = kwargs.pop("centroids_hnsw_ef_construct", 500)
centroids_hnsw_ef_search = kwargs.pop("centroids_hnsw_ef_search", 100)
centroids_hnsw_ef_construct = kwargs.pop(
"centroids_hnsw_ef_construct", 500)
centroids_hnsw_ef_search = kwargs.pop(
"centroids_hnsw_ef_search", 100)
mapping = default_text_mapping(
dimension,
method_name,
@ -303,7 +320,8 @@ class LindormVectorStore(BaseVector):
centroids_hnsw_ef_search=centroids_hnsw_ef_search,
**kwargs,
)
self._client.indices.create(index=self._collection_name.lower(), body=mapping)
self._client.indices.create(
index=self._collection_name.lower(), body=mapping)
redis_client.set(collection_exist_cache_key, 1, ex=3600)
# logger.info(f"create index success: {self._collection_name}")
@ -364,7 +382,8 @@ def default_text_mapping(dimension: int, method_name: str, **kwargs: Any) -> dic
}
if excludes_from_source:
mapping["mappings"]["_source"] = {"excludes": excludes_from_source} # e.g. {"excludes": ["vector_field"]}
# e.g. {"excludes": ["vector_field"]}
mapping["mappings"]["_source"] = {"excludes": excludes_from_source}
if method_name == "ivfpq" and routing_field is not None:
mapping["settings"]["index"]["knn_routing"] = True
@ -405,7 +424,8 @@ def default_text_search_query(
# build complex search_query when either of must/must_not/should/filter is specified
if must:
if not isinstance(must, list):
raise RuntimeError(f"unexpected [must] clause with {type(filters)}")
raise RuntimeError(
f"unexpected [must] clause with {type(filters)}")
if query_clause not in must:
must.append(query_clause)
else:
@ -415,19 +435,22 @@ def default_text_search_query(
if must_not:
if not isinstance(must_not, list):
raise RuntimeError(f"unexpected [must_not] clause with {type(filters)}")
raise RuntimeError(
f"unexpected [must_not] clause with {type(filters)}")
boolean_query["must_not"] = must_not
if should:
if not isinstance(should, list):
raise RuntimeError(f"unexpected [should] clause with {type(filters)}")
raise RuntimeError(
f"unexpected [should] clause with {type(filters)}")
boolean_query["should"] = should
if minimum_should_match != 0:
boolean_query["minimum_should_match"] = minimum_should_match
if filters:
if not isinstance(filters, list):
raise RuntimeError(f"unexpected [filter] clause with {type(filters)}")
raise RuntimeError(
f"unexpected [filter] clause with {type(filters)}")
boolean_query["filter"] = filters
search_query = {"size": k, "query": {"bool": boolean_query}}
@ -471,8 +494,10 @@ def default_vector_search_query(
if filters is not None:
# when using filter, transform filter from List[Dict] to Dict as valid format
filters = {"bool": {"must": filters}} if len(filters) > 1 else filters[0]
search_query["query"]["knn"][vector_field]["filter"] = filters # filter should be Dict
filters = {"bool": {"must": filters}} if len(
filters) > 1 else filters[0]
# filter should be Dict
search_query["query"]["knn"][vector_field]["filter"] = filters
if filter_type:
final_ext["lvector"]["filter_type"] = filter_type
@ -489,7 +514,8 @@ class LindormVectorStoreFactory(AbstractVectorFactory):
else:
dataset_id = dataset.id
collection_name = Dataset.gen_collection_name_by_id(dataset_id)
dataset.index_struct = json.dumps(self.gen_index_struct_dict(VectorType.LINDORM, collection_name))
dataset.index_struct = json.dumps(
self.gen_index_struct_dict(VectorType.LINDORM, collection_name))
lindorm_config = LindormVectorStoreConfig(
hosts=dify_config.LINDORM_URL,
username=dify_config.LINDORM_USERNAME,

View File

@ -49,11 +49,13 @@ class QuestionClassifierNode(LLMNode):
variable_pool = self.graph_runtime_state.variable_pool
# extract variables
variable = variable_pool.get(node_data.query_variable_selector) if node_data.query_variable_selector else None
variable = variable_pool.get(
node_data.query_variable_selector) if node_data.query_variable_selector else None
query = variable.value if variable else None
variables = {"query": query}
# fetch model config
model_instance, model_config = self._fetch_model_config(node_data.model)
model_instance, model_config = self._fetch_model_config(
node_data.model)
# fetch memory
memory = self._fetch_memory(
node_data_memory=node_data.memory,
@ -61,7 +63,8 @@ class QuestionClassifierNode(LLMNode):
)
# fetch instruction
node_data.instruction = node_data.instruction or ""
node_data.instruction = variable_pool.convert_template(node_data.instruction).text
node_data.instruction = variable_pool.convert_template(
node_data.instruction).text
files: Sequence[File] = (
self._fetch_files(
@ -184,12 +187,15 @@ class QuestionClassifierNode(LLMNode):
variable_mapping = {"query": node_data.query_variable_selector}
variable_selectors = []
if node_data.instruction:
variable_template_parser = VariableTemplateParser(template=node_data.instruction)
variable_selectors.extend(variable_template_parser.extract_variable_selectors())
variable_template_parser = VariableTemplateParser(
template=node_data.instruction)
variable_selectors.extend(
variable_template_parser.extract_variable_selectors())
for variable_selector in variable_selectors:
variable_mapping[variable_selector.variable] = variable_selector.value_selector
variable_mapping = {node_id + "." + key: value for key, value in variable_mapping.items()}
variable_mapping = {node_id + "." + key: value for key,
value in variable_mapping.items()}
return variable_mapping
@ -210,7 +216,8 @@ class QuestionClassifierNode(LLMNode):
context: Optional[str],
) -> int:
prompt_transform = AdvancedPromptTransform(with_variable_tmpl=True)
prompt_template = self._get_prompt_template(node_data, query, None, 2000)
prompt_template = self._get_prompt_template(
node_data, query, None, 2000)
prompt_messages = prompt_transform.get_prompt(
prompt_template=prompt_template,
inputs={},
@ -223,13 +230,15 @@ class QuestionClassifierNode(LLMNode):
)
rest_tokens = 2000
model_context_tokens = model_config.model_schema.model_properties.get(ModelPropertyKey.CONTEXT_SIZE)
model_context_tokens = model_config.model_schema.model_properties.get(
ModelPropertyKey.CONTEXT_SIZE)
if model_context_tokens:
model_instance = ModelInstance(
provider_model_bundle=model_config.provider_model_bundle, model=model_config.model
)
curr_message_tokens = model_instance.get_llm_num_tokens(prompt_messages)
curr_message_tokens = model_instance.get_llm_num_tokens(
prompt_messages)
max_tokens = 0
for parameter_rule in model_config.model_schema.parameter_rules:
@ -270,7 +279,8 @@ class QuestionClassifierNode(LLMNode):
prompt_messages: list[LLMNodeChatModelMessage] = []
if model_mode == ModelMode.CHAT:
system_prompt_messages = LLMNodeChatModelMessage(
role=PromptMessageRole.SYSTEM, text=QUESTION_CLASSIFIER_SYSTEM_PROMPT.format(histories=memory_str)
role=PromptMessageRole.SYSTEM, text=QUESTION_CLASSIFIER_SYSTEM_PROMPT.format(
histories=memory_str)
)
prompt_messages.append(system_prompt_messages)
user_prompt_message_1 = LLMNodeChatModelMessage(
@ -311,4 +321,5 @@ class QuestionClassifierNode(LLMNode):
)
else:
raise InvalidModelTypeError(f"Model mode {model_mode} not support.")
raise InvalidModelTypeError(
f"Model mode {model_mode} not support.")

View File

@ -68,7 +68,8 @@ def test_executor_with_json_body_and_object_variable():
system_variables={},
user_inputs={},
)
variable_pool.add(["pre_node_id", "object"], {"name": "John Doe", "age": 30, "email": "john@example.com"})
variable_pool.add(["pre_node_id", "object"], {
"name": "John Doe", "age": 30, "email": "john@example.com"})
# Prepare the node data
node_data = HttpRequestNodeData(
@ -102,7 +103,8 @@ def test_executor_with_json_body_and_object_variable():
assert executor.url == "https://api.example.com/data"
assert executor.headers == {"Content-Type": "application/json"}
assert executor.params == {}
assert executor.json == {"name": "John Doe", "age": 30, "email": "john@example.com"}
assert executor.json == {"name": "John Doe",
"age": 30, "email": "john@example.com"}
assert executor.data is None
assert executor.files is None
assert executor.content is None
@ -123,7 +125,8 @@ def test_executor_with_json_body_and_nested_object_variable():
system_variables={},
user_inputs={},
)
variable_pool.add(["pre_node_id", "object"], {"name": "John Doe", "age": 30, "email": "john@example.com"})
variable_pool.add(["pre_node_id", "object"], {
"name": "John Doe", "age": 30, "email": "john@example.com"})
# Prepare the node data
node_data = HttpRequestNodeData(
@ -157,7 +160,8 @@ def test_executor_with_json_body_and_nested_object_variable():
assert executor.url == "https://api.example.com/data"
assert executor.headers == {"Content-Type": "application/json"}
assert executor.params == {}
assert executor.json == {"object": {"name": "John Doe", "age": 30, "email": "john@example.com"}}
assert executor.json == {"object": {
"name": "John Doe", "age": 30, "email": "john@example.com"}}
assert executor.data is None
assert executor.files is None
assert executor.content is None

View File

@ -23,7 +23,8 @@ def test_plain_text_to_dict():
assert _plain_text_to_dict("aa\n cc:") == {"aa": "", "cc": ""}
assert _plain_text_to_dict("aa:bb\n cc:dd") == {"aa": "bb", "cc": "dd"}
assert _plain_text_to_dict("aa:bb\n cc:dd\n") == {"aa": "bb", "cc": "dd"}
assert _plain_text_to_dict("aa:bb\n\n cc : dd\n\n") == {"aa": "bb", "cc": "dd"}
assert _plain_text_to_dict("aa:bb\n\n cc : dd\n\n") == {
"aa": "bb", "cc": "dd"}
def test_http_request_node_binary_file(monkeypatch):
@ -189,7 +190,8 @@ def test_http_request_node_form_with_file(monkeypatch):
def attr_checker(*args, **kwargs):
assert kwargs["data"] == {"name": "test"}
assert kwargs["files"] == {"file": (None, b"test", "application/octet-stream")}
assert kwargs["files"] == {
"file": (None, b"test", "application/octet-stream")}
return httpx.Response(200, content=b"")
monkeypatch.setattr(

View File

@ -29,3 +29,6 @@ NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=60000
# CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
NEXT_PUBLIC_CSP_WHITELIST=
# Github Access Token, used for invoking Github API
NEXT_PUBLIC_GITHUB_ACCESS_TOKEN=

View File

@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import useSWR from 'swr'
// Components
import ExternalAPIPanel from '../../components/datasets/external-api/external-api-panel'
@ -28,6 +27,7 @@ import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { useAppContext } from '@/context/app-context'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { useQuery } from '@tanstack/react-query'
const Container = () => {
const { t } = useTranslation()
@ -47,7 +47,13 @@ const Container = () => {
defaultTab: 'dataset',
})
const containerRef = useRef<HTMLDivElement>(null)
const { data } = useSWR(activeTab === 'dataset' ? null : '/datasets/api-base-info', fetchDatasetApiBaseUrl)
const { data } = useQuery(
{
queryKey: ['datasetApiBaseInfo', activeTab],
queryFn: () => fetchDatasetApiBaseUrl('/datasets/api-base-info'),
enabled: activeTab !== 'dataset',
},
)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')

View File

@ -8,6 +8,7 @@ import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context'
import { TanstackQueryIniter } from '@/context/query-client'
const Layout = ({ children }: { children: ReactNode }) => {
return (
@ -21,7 +22,9 @@ const Layout = ({ children }: { children: ReactNode }) => {
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
<TanstackQueryIniter>
{children}
</TanstackQueryIniter>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>

View File

@ -0,0 +1,67 @@
'use client'
import { toolNeko } from '@/app/components/plugins/card/card-mock'
import { PluginSource } from '@/app/components/plugins/types'
import { useModalContext } from '@/context/modal-context'
import React from 'react'
const UpdatePlugin = () => {
const { setShowUpdatePluginModal } = useModalContext()
const handleUpdateFromMarketPlace = () => {
setShowUpdatePluginModal({
payload: {
type: PluginSource.marketplace,
marketPlace: {
originalPackageInfo: {
id: 'langgenius/neko:0.0.1@9e57d693739287c0efdc96847d7ed959ca93f70aa704471f2eb7ed3313821824',
payload: toolNeko as any,
},
targetPackageInfo: {
id: 'target_xxx',
version: '1.2.3',
},
},
},
onCancelCallback: () => {
console.log('canceled')
},
onSaveCallback: () => {
console.log('saved')
},
})
}
const handleUpdateFromGithub = () => {
setShowUpdatePluginModal({
payload: {
type: PluginSource.github,
github: {
originalPackageInfo: {
id: '111',
repo: 'aaa/bbb',
version: 'xxx',
url: 'aaa/bbb',
currVersion: '1.2.3',
currPackage: 'pack1',
} as any,
},
},
onCancelCallback: () => {
console.log('canceled')
},
onSaveCallback: () => {
console.log('saved')
},
})
}
return (
<div>
<div></div>
<div className='flex space-x-1'>
<div className='underline cursor-pointer' onClick={handleUpdateFromMarketPlace}> Marketplace</div>
<div className='underline cursor-pointer' onClick={handleUpdateFromGithub}> GitHub</div>
</div>
</div>
)
}
export default React.memo(UpdatePlugin)

View File

@ -27,10 +27,12 @@ import { MAX_TOOLS_NUM } from '@/config'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import AddToolModal from '@/app/components/tools/add-tool-modal'
// import AddToolModal from '@/app/components/tools/add-tool-modal'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import { updateBuiltInToolCredential } from '@/service/tools'
import cn from '@/utils/classnames'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
const AgentTools: FC = () => {
@ -81,6 +83,21 @@ const AgentTools: FC = () => {
const [isDeleting, setIsDeleting] = useState<number>(-1)
const handleSelectTool = (tool: ToolDefaultValue) => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.push({
provider_id: tool.provider_id,
provider_type: tool.provider_type as CollectionType,
provider_name: tool.provider_name,
tool_name: tool.tool_name,
tool_label: tool.tool_label,
tool_parameters: tool.params,
enabled: true,
})
})
setModelConfig(newModelConfig)
}
return (
<>
<Panel
@ -107,7 +124,14 @@ const AgentTools: FC = () => {
{tools.length < MAX_TOOLS_NUM && (
<>
<div className='ml-3 mr-1 h-3.5 w-px bg-gray-200'></div>
<OperationBtn type="add" onClick={() => setIsShowChooseTool(true)} />
<ToolPicker
trigger={<OperationBtn type="add" />}
isShow={isShowChooseTool}
onShowChange={setIsShowChooseTool}
disabled={false}
supportAddCustomTool
onSelect={handleSelectTool}
/>
</>
)}
</div>
@ -125,8 +149,8 @@ const AgentTools: FC = () => {
{item.isDeleted && <DefaultToolIcon className='w-5 h-5' />}
{!item.isDeleted && (
<div className={cn((item.notAuthor || !item.enabled) && 'opacity-50')}>
{typeof item.icon === 'string' && <div className='w-5 h-5 bg-cover bg-center rounded-md' style={{ backgroundImage: `url(${item.icon})` }}/>}
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background}/>}
{typeof item.icon === 'string' && <div className='w-5 h-5 bg-cover bg-center rounded-md' style={{ backgroundImage: `url(${item.icon})` }} />}
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
</div>
)}
<div
@ -245,9 +269,6 @@ const AgentTools: FC = () => {
))}
</div >
</Panel >
{isShowChooseTool && (
<AddToolModal onHide={() => setIsShowChooseTool(false)} />
)}
{isShowSettingTool && (
<SettingBuiltInTool
toolName={currentTool?.tool_name as string}

View File

@ -71,8 +71,9 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
import { fetchFileUploadConfig } from '@/service/common'
import { correctProvider } from '@/utils'
interface PublishConfig {
type PublishConfig = {
modelConfig: ModelConfig
completionParams: FormValue
}
@ -156,6 +157,7 @@ const Configuration: FC = () => {
const setCompletionParams = (value: FormValue) => {
const params = { ...value }
// eslint-disable-next-line ts/no-use-before-define
if ((!params.stop || params.stop.length === 0) && (modeModeTypeRef.current === ModelModeType.completion)) {
params.stop = getTempStop()
setTempStop([])
@ -164,7 +166,7 @@ const Configuration: FC = () => {
}
const [modelConfig, doSetModelConfig] = useState<ModelConfig>({
provider: 'openai',
provider: 'langgenius/openai/openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.unset,
configs: {
@ -187,7 +189,7 @@ const Configuration: FC = () => {
const isAgent = mode === 'agent-chat'
const isOpenAI = modelConfig.provider === 'openai'
const isOpenAI = modelConfig.provider === 'langgenius/openai/openai'
const [collectionList, setCollectionList] = useState<Collection[]>([])
useEffect(() => {
@ -356,6 +358,7 @@ const Configuration: FC = () => {
const [canReturnToSimpleMode, setCanReturnToSimpleMode] = useState(true)
const setPromptMode = async (mode: PromptMode) => {
if (mode === PromptMode.advanced) {
// eslint-disable-next-line ts/no-use-before-define
await migrateToDefaultPrompt()
setCanReturnToSimpleMode(true)
}
@ -540,8 +543,19 @@ const Configuration: FC = () => {
if (modelConfig.retriever_resource)
setCitationConfig(modelConfig.retriever_resource)
if (modelConfig.annotation_reply)
setAnnotationConfig(modelConfig.annotation_reply, true)
if (modelConfig.annotation_reply) {
let annotationConfig = modelConfig.annotation_reply
if (modelConfig.annotation_reply.enabled) {
annotationConfig = {
...modelConfig.annotation_reply,
embedding_model: {
...modelConfig.annotation_reply.embedding_model,
embedding_provider_name: correctProvider(modelConfig.annotation_reply.embedding_model.embedding_provider_name),
},
}
}
setAnnotationConfig(annotationConfig, true)
}
if (modelConfig.sensitive_word_avoidance)
setModerationConfig(modelConfig.sensitive_word_avoidance)
@ -551,7 +565,7 @@ const Configuration: FC = () => {
const config = {
modelConfig: {
provider: model.provider,
provider: correctProvider(model.provider),
model_id: model.name,
mode: model.mode,
configs: {
@ -605,6 +619,10 @@ const Configuration: FC = () => {
...tool,
isDeleted: res.deleted_tools?.includes(tool.tool_name),
notAuthor: collectionList.find(c => tool.provider_id === c.id)?.is_team_authorization === false,
...(tool.provider_type === 'builtin' ? {
provider_id: correctProvider(tool.provider_name),
provider_name: correctProvider(tool.provider_name),
} : {}),
}
}),
} : DEFAULT_AGENT_SETTING,
@ -622,6 +640,12 @@ const Configuration: FC = () => {
retrieval_model: RETRIEVE_TYPE.multiWay,
...modelConfig.dataset_configs,
...retrievalConfig,
...(retrievalConfig.reranking_model ? {
reranking_model: {
...retrievalConfig.reranking_model,
reranking_provider_name: correctProvider(modelConfig.dataset_configs.reranking_model.reranking_provider_name),
},
} : {}),
})
setHasFetchedDetail(true)
})

View File

@ -44,6 +44,7 @@ import Tooltip from '@/app/components/base/tooltip'
import { CopyIcon } from '@/app/components/base/copy-icon'
import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { correctProvider } from '@/utils'
dayjs.extend(utc)
dayjs.extend(timezone)
@ -324,7 +325,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
})?.name ?? 'custom'
const modelName = (detail.model_config as any).model?.name
const provideName = (detail.model_config as any).model?.provider as any
const provideName = correctProvider((detail.model_config as any).model?.provider as any)
const {
currentModel,
currentProvider,

View File

@ -60,7 +60,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
'/code-based-extension?module=moderation',
fetchCodeBasedExtensionList,
)
const openaiProvider = modelProviders?.data.find(item => item.provider === 'openai')
const openaiProvider = modelProviders?.data.find(item => item.provider === 'langgenius/openai/openai')
const systemOpenaiProviderEnabled = openaiProvider?.system_configuration.enabled
const systemOpenaiProviderQuota = systemOpenaiProviderEnabled ? openaiProvider?.system_configuration.quota_configurations.find(item => item.quota_type === openaiProvider.system_configuration.current_quota_type) : undefined
const systemOpenaiProviderCanUse = systemOpenaiProviderQuota?.is_valid

View File

@ -1,3 +1,4 @@
import type { Components } from 'react-markdown'
import ReactMarkdown from 'react-markdown'
import ReactEcharts from 'echarts-for-react'
import 'katex/dist/katex.min.css'
@ -9,8 +10,7 @@ import RehypeRaw from 'rehype-raw'
import SyntaxHighlighter from 'react-syntax-highlighter'
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
import type { RefObject } from 'react'
import { Component, memo, useEffect, useMemo, useRef, useState } from 'react'
import type { CodeComponent } from 'react-markdown/lib/ast-to-react'
import { Component, createContext, memo, useContext, useEffect, useMemo, useRef, useState } from 'react'
import cn from '@/utils/classnames'
import CopyBtn from '@/app/components/base/copy-btn'
import SVGBtn from '@/app/components/base/svg'
@ -22,6 +22,7 @@ import AudioGallery from '@/app/components/base/audio-gallery'
import SVGRenderer from '@/app/components/base/svg-gallery'
import MarkdownButton from '@/app/components/base/markdown-blocks/button'
import MarkdownForm from '@/app/components/base/markdown-blocks/form'
import type { ElementContentMap } from 'hast'
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
const capitalizationLanguageNameMap: Record<string, string> = {
@ -56,7 +57,7 @@ const getCorrectCapitalizationLanguageName = (language: string) => {
return language.charAt(0).toUpperCase() + language.substring(1)
}
const preprocessLaTeX = (content: string) => {
const preprocessLaTeX = (content?: string) => {
if (typeof content !== 'string')
return content
return content.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`)
@ -99,6 +100,20 @@ const useLazyLoad = (ref: RefObject<Element>): boolean => {
return isIntersecting
}
const PreContext = createContext({
// if children not in PreContext, just leave inline true
inline: true,
})
const PreBlock: Components['pre'] = (props) => {
const { ...rest } = props
return <PreContext.Provider value={{
inline: false,
}}>
<pre {...rest} />
</PreContext.Provider>
}
// **Add code block
// Avoid error #185 (Maximum update depth exceeded.
// This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
@ -112,7 +127,8 @@ const useLazyLoad = (ref: RefObject<Element>): boolean => {
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
// or use the non-minified dev environment for full errors and additional helpful warnings.
const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }) => {
const CodeBlock: Components['code'] = memo(({ className, children, ...props }) => {
const { inline } = useContext(PreContext)
const [isSVG, setIsSVG] = useState(true)
const match = /language-(\w+)/.exec(className || '')
const language = match?.[1]
@ -122,7 +138,7 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
try {
return JSON.parse(String(children).replace(/\n$/, ''))
}
catch (error) {}
catch {}
}
return JSON.parse('{"title":{"text":"ECharts error - Wrong JSON format."}}')
}, [language, children])
@ -192,52 +208,50 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
</div>
)
})
CodeBlock.displayName = 'CodeBlock'
// CodeBlock.displayName = 'CodeBlock'
const VideoBlock: CodeComponent = memo(({ node }) => {
const srcs = node.children.filter(child => 'properties' in child).map(child => (child as any).properties.src)
const VideoBlock: Components['video'] = memo(({ node }) => {
const srcs = node!.children.filter(child => 'properties' in child).map(child => (child as any).properties.src)
if (srcs.length === 0)
return null
return <VideoGallery key={srcs.join()} srcs={srcs} />
})
VideoBlock.displayName = 'VideoBlock'
// VideoBlock.displayName = 'VideoBlock'
const AudioBlock: CodeComponent = memo(({ node }) => {
const srcs = node.children.filter(child => 'properties' in child).map(child => (child as any).properties.src)
const AudioBlock: Components['audio'] = memo(({ node }) => {
const srcs = node!.children.filter(child => 'properties' in child).map(child => (child as any).properties.src)
if (srcs.length === 0)
return null
return <AudioGallery key={srcs.join()} srcs={srcs} />
})
AudioBlock.displayName = 'AudioBlock'
// AudioBlock.displayName = 'AudioBlock'
const Paragraph = (paragraph: any) => {
const { node }: any = paragraph
const children_node = node.children
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
return (
<>
<ImageGallery srcs={[children_node[0].properties.src]} />
<p>{paragraph.children.slice(1)}</p>
</>
)
}
return <p>{paragraph.children}</p>
const Paragraph: Components['p'] = ({ node, children }) => {
const children_node = node!.children
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img')
return <ImageGallery srcs={[children_node?.[0]?.properties?.src as string]} />
return <p>{children}</p>
}
const Img = ({ src }: any) => {
return (<ImageGallery srcs={[src]} />)
const Img: Components['img'] = ({ src }) => {
return (<ImageGallery srcs={[src!]} />)
}
const Link = ({ node, ...props }: any) => {
if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) {
const Link: Components['a'] = ({ node, ...props }) => {
if (node!.properties?.href && node!.properties.href?.toString().startsWith('abbr')) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { onSend } = useChatContext()
const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1])
return <abbr className="underline decoration-dashed !decoration-primary-700 cursor-pointer" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value}>{node.children[0]?.value}</abbr>
const hidden_text = decodeURIComponent(node!.properties.href.toString().split('abbr:')[1])
const title = (node!.children[0] as ElementContentMap['text'])?.value
return <abbr className="underline decoration-dashed !decoration-primary-700 cursor-pointer" onClick={() => onSend?.(hidden_text)} title={title}>{title}</abbr>
}
else {
return <a {...props} target="_blank" className="underline decoration-dashed !decoration-primary-700 cursor-pointer">{node.children[0] ? node.children[0]?.value : 'Download'}</a>
const firstChild = node?.children?.[0] as ElementContentMap['text'] | undefined
return <a {...props} target="_blank" className="underline decoration-dashed !decoration-primary-700 cursor-pointer">{
firstChild
? firstChild.value
: 'Download'
}</a>
}
}
@ -266,6 +280,7 @@ export function Markdown(props: { content: string; className?: string }) {
]}
disallowedElements={['script', 'iframe', 'head', 'html', 'meta', 'link', 'style', 'body']}
components={{
pre: PreBlock,
code: CodeBlock,
img: Img,
video: VideoBlock,
@ -275,7 +290,6 @@ export function Markdown(props: { content: string; className?: string }) {
button: MarkdownButton,
form: MarkdownForm,
}}
linkTarget='_blank'
>
{/* Markdown detect has problem. */}
{latexContent}

View File

@ -18,7 +18,7 @@ export class WorkflowVariableBlockNode extends DecoratorNode<JSX.Element> {
}
static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap)
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__key)
}
isInline(): boolean {

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import React, { Fragment, useEffect, useState } from 'react'
import { Combobox, Listbox, Transition } from '@headlessui/react'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
import Badge from '../badge/index'
import { useTranslation } from 'react-i18next'
import classNames from '@/utils/classnames'
import {
@ -289,6 +290,7 @@ type PortalSelectProps = {
onSelect: (value: Item) => void
items: Item[]
placeholder?: string
installedValue?: string | number
renderTrigger?: (value?: Item) => JSX.Element | null
triggerClassName?: string
triggerClassNameFn?: (open: boolean) => string
@ -302,6 +304,7 @@ const PortalSelect: FC<PortalSelectProps> = ({
onSelect,
items,
placeholder,
installedValue,
renderTrigger,
triggerClassName,
triggerClassNameFn,
@ -313,7 +316,7 @@ const PortalSelect: FC<PortalSelectProps> = ({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const localPlaceholder = placeholder || t('common.placeholder.select')
const selectedItem = items.find(item => item.value === value)
const selectedItem = value ? items.find(item => item.value === value) : undefined
return (
<PortalToFollowElem
@ -328,19 +331,20 @@ const PortalSelect: FC<PortalSelectProps> = ({
: (
<div
className={classNames(`
flex items-center justify-between px-2.5 h-9 rounded-lg border-0 bg-gray-100 text-sm ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}
group flex items-center justify-between px-2.5 h-9 rounded-lg border-0 bg-components-input-bg-normal hover:bg-state-base-hover-alt text-sm ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}
`, triggerClassName, triggerClassNameFn?.(open))}
title={selectedItem?.name}
>
<span
className={`
grow truncate
${!selectedItem?.name && 'text-gray-400'}
${!selectedItem?.name && 'text-components-input-text-placeholder'}
`}
>
{selectedItem?.name ?? localPlaceholder}
</span>
<ChevronDownIcon className='shrink-0 h-4 w-4 text-gray-400' />
<div className='mx-0.5'>{installedValue && selectedItem && selectedItem.value !== installedValue && <Badge>{installedValue} {'->'} {selectedItem.value} </Badge>}</div>
<ChevronDownIcon className='shrink-0 h-4 w-4 text-text-quaternary group-hover:text-text-secondary' />
</div>
)}
@ -353,8 +357,8 @@ const PortalSelect: FC<PortalSelectProps> = ({
<div
key={item.value}
className={`
flex items-center justify-between px-2.5 h-9 cursor-pointer rounded-lg hover:bg-gray-100 text-gray-700
${item.value === value && 'bg-gray-100'}
flex items-center justify-between px-2.5 h-9 cursor-pointer rounded-lg hover:bg-state-base-hover text-text-secondary
${item.value === value && 'bg-state-base-hover'}
`}
title={item.name}
onClick={() => {
@ -366,7 +370,10 @@ const PortalSelect: FC<PortalSelectProps> = ({
className='w-0 grow truncate'
title={item.name}
>
{item.name}
<span className='truncate'>{item.name}</span>
{item.value === installedValue && (
<Badge uppercase={true} className='shrink-0 ml-1'>INSTALLED</Badge>
)}
</span>
{!hideChecked && item.value === value && (
<CheckIcon className='shrink-0 h-4 w-4 text-text-accent' />

View File

@ -2,7 +2,7 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react'
import cn from '@/utils/classnames'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import { usePluginPageContext } from '../../plugins/plugin-page/context'
import { useInstalledPluginList } from '@/service/use-plugins'
type Option = {
value: string
text: string
@ -23,7 +23,7 @@ const TabSlider: FC<TabSliderProps> = ({
}) => {
const [activeIndex, setActiveIndex] = useState(options.findIndex(option => option.value === value))
const [sliderStyle, setSliderStyle] = useState({})
const pluginList = usePluginPageContext(v => v.installedPluginList)
const { data: pluginList } = useInstalledPluginList()
const updateSliderStyle = (index: number) => {
const tabElement = document.getElementById(`tab-${index}`)
@ -40,7 +40,7 @@ const TabSlider: FC<TabSliderProps> = ({
const newIndex = options.findIndex(option => option.value === value)
setActiveIndex(newIndex)
updateSliderStyle(newIndex)
}, [value, options])
}, [value, options, pluginList])
return (
<div className={cn(className, 'inline-flex p-0.5 rounded-[10px] bg-components-segmented-control-bg-normal relative items-center justify-center')}>
@ -67,13 +67,15 @@ const TabSlider: FC<TabSliderProps> = ({
}}
>
{option.text}
{/* if no plugin installed, the badge won't show */}
{option.value === 'plugins'
&& (pluginList?.plugins.length ?? 0) > 0
&& <Badge
size='s'
uppercase={true}
state={BadgeState.Default}
>
{pluginList.length}
{pluginList?.plugins.length}
</Badge>
}
</div>

View File

@ -74,7 +74,7 @@ const UpgradeBtn: FC<Props> = ({
onClick={onClick}
>
<GoldCoin className='mr-1 w-3.5 h-3.5' />
<div className='text-xs font-normal'>{t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)}</div>
<div className='text-xs font-normal text-nowrap'>{t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)}</div>
<Sparkles
className='absolute -right-1 -top-2 w-4 h-5 bg-cover'
/>

View File

@ -55,7 +55,7 @@ export const Heading = function H2({
export function Row({ children }: IChildrenProps) {
return (
<div className="grid items-start grid-cols-1 gap-x-16 gap-y-10 xl:max-w-none xl:grid-cols-2">
<div className="grid items-start grid-cols-1 gap-x-16 gap-y-10 xl:!max-w-none xl:grid-cols-2">
{children}
</div>
)

View File

@ -6,15 +6,12 @@ import { RiArrowDownSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { switchWorkspace } from '@/service/common'
import { useWorkspacesContext } from '@/context/workspace-context'
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
import { useProviderContext } from '@/context/provider-context'
import { ToastContext } from '@/app/components/base/toast'
const WorkplaceSelector = () => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { workspaces } = useWorkspacesContext()
const { enableBilling } = useProviderContext()
const currentWorkspace = workspaces.find(v => v.current)
const handleSwitchWorkspace = async (tenant_id: string) => {
try {
@ -68,12 +65,7 @@ const WorkplaceSelector = () => {
workspaces.map(workspace => (
<div className='flex py-1 pl-3 pr-2 items-center gap-2 self-stretch hover:bg-state-base-hover rounded-lg' key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
<div className='flex items-center justify-center w-7 h-7 bg-[#EFF4FF] rounded-md text-xs font-medium text-primary-600'>{workspace.name[0].toLocaleUpperCase()}</div>
<div className='line-clamp-1 flex-grow overflow-hidden text-text-secondary text-ellipsis system-md-regular'>{workspace.name}</div>
{enableBilling && (
<div className='select-none'>
<HeaderBillingBtn isDisplayOnly={true} />
</div>
)}
<div className='line-clamp-1 flex-grow overflow-hidden text-text-secondary text-ellipsis system-md-regular cursor-pointer'>{workspace.name}</div>
</div>
))
}

View File

@ -123,7 +123,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
const [collapse, setCollapse] = useState(false)
const {
plugins,
plugins = [],
queryPlugins,
queryPluginsWithDebounced,
isLoading: isPluginsLoading,

View File

@ -17,7 +17,7 @@ const PluginsNav = ({
<Link href="/plugins" className={classNames(
className, 'group',
)}>
<div className='flex flex-row p-1.5 gap-0.5 items-center justify-center rounded-xl system-xs-medium-uppercase hover:bg-state-base-hover text-text-tertiary hover:text-text-secondary'>
<div className='flex flex-row h-8 p-1.5 gap-0.5 items-center justify-center rounded-xl system-sm-medium-uppercase hover:bg-state-base-hover text-text-tertiary hover:text-text-secondary'>
<div className='flex w-4 h-4 justify-center items-center'>
<Group />
</div>

View File

@ -2,7 +2,6 @@
import type { FC } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import copy from 'copy-to-clipboard'
import {
RiClipboardLine,
} from '@remixicon/react'
@ -11,16 +10,19 @@ import { ClipboardCheck } from '../../base/icons/src/vender/line/files'
import Tooltip from '../../base/tooltip'
import cn from '@/utils/classnames'
import ActionButton from '@/app/components/base/action-button'
type Props = {
label: string
labelWidthClassName?: string
value: string
valueMaxWidthClassName?: string
}
const KeyValueItem: FC<Props> = ({
label,
labelWidthClassName = 'w-10',
value,
valueMaxWidthClassName = 'max-w-[162px]',
}) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
@ -46,7 +48,7 @@ const KeyValueItem: FC<Props> = ({
<div className='flex items-center gap-1'>
<span className={cn('flex flex-col justify-center items-start text-text-tertiary system-xs-medium', labelWidthClassName)}>{label}</span>
<div className='flex justify-center items-center gap-0.5'>
<span className='max-w-[300px] truncate system-xs-medium text-text-secondary'>
<span className={cn(valueMaxWidthClassName, ' truncate system-xs-medium text-text-secondary')}>
{value}
</span>
<Tooltip popupContent={t(`common.operation.${isCopied ? 'copied' : 'copy'}`)} position='top'>

View File

@ -1,6 +1,64 @@
import type { PluginDeclaration } from '../types'
import { PluginType } from '../types'
export const toolNeko: PluginDeclaration = {
version: '0.0.1',
author: 'langgenius',
name: 'neko',
description: {
en_US: 'Neko is a cute cat.',
zh_Hans: '这是一只可爱的小猫。',
pt_BR: 'Neko is a cute cat.',
ja_JP: 'Neko is a cute cat.',
},
icon: '241e5209ecc8b5ce6b7a29a8e50388e9c75b89c3047c6ecd8e552f26de758883.svg',
label: {
en_US: 'Neko',
zh_Hans: 'Neko',
pt_BR: 'Neko',
ja_JP: 'Neko',
},
category: 'extension' as any,
created_at: '2024-07-12T08:03:44.658609Z',
resource: {
memory: 1048576,
permission: {
tool: {
enabled: true,
},
model: {
enabled: true,
llm: true,
text_embedding: false,
rerank: false,
tts: false,
speech2text: false,
moderation: false,
},
node: null,
endpoint: {
enabled: true,
},
storage: {
enabled: true,
size: 1048576,
},
},
},
plugins: {
tools: null,
models: null,
endpoints: [
'provider/neko.yaml',
],
},
tags: [],
verified: false,
tool: null,
model: null,
endpoint: null,
}
export const toolNotion = {
type: PluginType.tool,
org: 'Notion',

View File

@ -10,6 +10,7 @@ import Description from './base/description'
import Placeholder from './base/placeholder'
import cn from '@/utils/classnames'
import { useGetLanguage } from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
export type Props = {
className?: string
@ -22,6 +23,7 @@ export type Props = {
footer?: React.ReactNode
isLoading?: boolean
loadingFileName?: string
locale?: string
}
const Card = ({
@ -35,8 +37,10 @@ const Card = ({
footer,
isLoading = false,
loadingFileName,
locale: localeFromProps,
}: Props) => {
const locale = useGetLanguage()
const defaultLocale = useGetLanguage()
const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale
const { type, name, org, label, brief, icon, verified } = payload

View File

@ -0,0 +1,89 @@
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
type Tag = {
name: string
label: string
}
export const useTags = (translateFromOut?: TFunction) => {
const { t: translation } = useTranslation()
const t = translateFromOut || translation
const tags = [
{
name: 'search',
label: t('pluginTags.tags.search'),
},
{
name: 'image',
label: t('pluginTags.tags.image'),
},
{
name: 'videos',
label: t('pluginTags.tags.videos'),
},
{
name: 'weather',
label: t('pluginTags.tags.weather'),
},
{
name: 'finance',
label: t('pluginTags.tags.finance'),
},
{
name: 'design',
label: t('pluginTags.tags.design'),
},
{
name: 'travel',
label: t('pluginTags.tags.travel'),
},
{
name: 'social',
label: t('pluginTags.tags.social'),
},
{
name: 'news',
label: t('pluginTags.tags.news'),
},
{
name: 'medical',
label: t('pluginTags.tags.medical'),
},
{
name: 'productivity',
label: t('pluginTags.tags.productivity'),
},
{
name: 'education',
label: t('pluginTags.tags.education'),
},
{
name: 'business',
label: t('pluginTags.tags.business'),
},
{
name: 'entertainment',
label: t('pluginTags.tags.entertainment'),
},
{
name: 'utilities',
label: t('pluginTags.tags.utilities'),
},
{
name: 'other',
label: t('pluginTags.tags.other'),
},
]
const tagsMap = tags.reduce((acc, tag) => {
acc[tag.name] = tag
return acc
}, {} as Record<string, Tag>)
return {
tags,
tagsMap,
}
}

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { PluginDeclaration, PluginManifestInMarket } from '../../types'
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
import Card from '../../card'
import Button from '@/app/components/base/button'
import { pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils'
@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
type Props = {
payload?: PluginDeclaration | PluginManifestInMarket | null
payload?: Plugin | PluginDeclaration | PluginManifestInMarket | null
isMarketPayload?: boolean
isFailed: boolean
errMsg?: string | null

View File

@ -0,0 +1,69 @@
import Toast from '@/app/components/base/toast'
import { uploadGitHub } from '@/service/plugins'
import { Octokit } from '@octokit/core'
import { GITHUB_ACCESS_TOKEN } from '@/config'
export const useGitHubReleases = () => {
const fetchReleases = async (owner: string, repo: string) => {
try {
const octokit = new Octokit({
auth: GITHUB_ACCESS_TOKEN,
})
const res = await octokit.request('GET /repos/{owner}/{repo}/releases', {
owner,
repo,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
})
if (res.status !== 200) throw new Error('Failed to fetch releases')
const formattedReleases = res.data.map((release: any) => ({
tag_name: release.tag_name,
assets: release.assets.map((asset: any) => ({
browser_download_url: asset.browser_download_url,
name: asset.name,
})),
}))
return formattedReleases
}
catch (error) {
Toast.notify({
type: 'error',
message: 'Failed to fetch repository releases',
})
return []
}
}
return { fetchReleases }
}
export const useGitHubUpload = () => {
const handleUpload = async (
repoUrl: string,
selectedVersion: string,
selectedPackage: string,
onSuccess?: (GitHubPackage: { manifest: any; unique_identifier: string }) => void,
) => {
try {
const response = await uploadGitHub(repoUrl, selectedVersion, selectedPackage)
const GitHubPackage = {
manifest: response.manifest,
unique_identifier: response.unique_identifier,
}
if (onSuccess) onSuccess(GitHubPackage)
return GitHubPackage
}
catch (error) {
Toast.notify({
type: 'error',
message: 'Error uploading package',
})
throw error
}
}
return { handleUpload }
}

View File

@ -1,30 +1,45 @@
'use client'
import React, { useState } from 'react'
import React, { useCallback, useState } from 'react'
import Modal from '@/app/components/base/modal'
import type { Item } from '@/app/components/base/select'
import type { GitHubUrlInfo, InstallState } from '@/app/components/plugins/types'
import type { InstallState } from '@/app/components/plugins/types'
import { useGitHubReleases } from '../hooks'
import { convertRepoToUrl, parseGitHubUrl } from '../utils'
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types'
import { InstallStepFromGitHub } from '../../types'
import Toast from '@/app/components/base/toast'
import SetURL from './steps/setURL'
import SetVersion from './steps/setVersion'
import SetPackage from './steps/setPackage'
import Installed from './steps/installed'
import SelectPackage from './steps/selectPackage'
import Installed from '../base/installed'
import Loaded from './steps/loaded'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { useTranslation } from 'react-i18next'
const i18nPrefix = 'plugin.installFromGitHub'
type InstallFromGitHubProps = {
updatePayload?: UpdateFromGitHubPayload
onClose: () => void
onSuccess: () => void
}
const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ onClose }) => {
const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, onClose, onSuccess }) => {
const { t } = useTranslation()
const { getIconUrl } = useGetIcon()
const { fetchReleases } = useGitHubReleases()
const [state, setState] = useState<InstallState>({
step: InstallStepFromGitHub.setUrl,
repoUrl: '',
step: updatePayload ? InstallStepFromGitHub.selectPackage : InstallStepFromGitHub.setUrl,
repoUrl: updatePayload?.originalPackageInfo?.repo
? convertRepoToUrl(updatePayload.originalPackageInfo.repo)
: '',
selectedVersion: '',
selectedPackage: '',
releases: [],
releases: updatePayload ? updatePayload.originalPackageInfo.releases : [],
})
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
const [manifest, setManifest] = useState<PluginDeclaration | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const versions: Item[] = state.releases.map(release => ({
value: release.tag_name,
@ -36,152 +51,151 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ onClose }) => {
.find(release => release.tag_name === state.selectedVersion)
?.assets
.map(asset => ({
value: asset.browser_download_url,
value: asset.name,
name: asset.name,
})) || [])
: []
const parseGitHubUrl = (url: string): GitHubUrlInfo => {
const githubUrlRegex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/
const match = url.match(githubUrlRegex)
const getTitle = useCallback(() => {
if (state.step === InstallStepFromGitHub.installed)
return t(`${i18nPrefix}.installedSuccessfully`)
if (state.step === InstallStepFromGitHub.installFailed)
return t(`${i18nPrefix}.installFailed`)
if (match) {
return {
isValid: true,
owner: match[1],
repo: match[2],
}
return updatePayload ? t(`${i18nPrefix}.updatePlugin`) : t(`${i18nPrefix}.installPlugin`)
}, [state.step])
const handleUrlSubmit = async () => {
const { isValid, owner, repo } = parseGitHubUrl(state.repoUrl)
if (!isValid || !owner || !repo) {
Toast.notify({
type: 'error',
message: t('plugin.error.inValidGitHubUrl'),
})
return
}
return { isValid: false }
await fetchReleases(owner, repo).then((fetchedReleases) => {
setState(prevState => ({
...prevState,
releases: fetchedReleases,
step: InstallStepFromGitHub.selectPackage,
}))
})
}
const handleInstall = async () => {
// try {
// const response = await installPackageFromGitHub({ repo: state.repoUrl, version: state.selectedVersion, package: state.selectedPackage })
// if (response.plugin_unique_identifier) {
// setState(prevState => ({...prevState, step: InstallStep.installed}))
// console.log('Package installed:')
// }
// else {
// console.error('Failed to install package:')
// }
// }
// catch (error) {
// console.error('Error installing package:')
// }
const handleError = (e: any, isInstall: boolean) => {
const message = e?.response?.message || t('plugin.installModal.installFailedDesc')
setErrorMsg(message)
setState(prevState => ({ ...prevState, step: isInstall ? InstallStepFromGitHub.installFailed : InstallStepFromGitHub.uploadFailed }))
}
const handleUploaded = async (GitHubPackage: any) => {
try {
const icon = await getIconUrl(GitHubPackage.manifest.icon)
setManifest({
...GitHubPackage.manifest,
icon,
})
setUniqueIdentifier(GitHubPackage.uniqueIdentifier)
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.readyToInstall }))
}
catch (e) {
handleError(e, false)
}
}
const handleUploadFail = useCallback((errorMsg: string) => {
setErrorMsg(errorMsg)
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.uploadFailed }))
}, [])
const handleInstalled = useCallback(() => {
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.installed }))
}
onSuccess()
}, [onSuccess])
const handleNext = async () => {
switch (state.step) {
case InstallStepFromGitHub.setUrl: {
const { isValid, owner, repo } = parseGitHubUrl(state.repoUrl)
if (!isValid || !owner || !repo) {
Toast.notify({
type: 'error',
message: t('plugin.error.inValidGitHubUrl'),
})
break
}
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`)
if (!res.ok)
throw new Error('Failed to fetch releases')
const data = await res.json()
const formattedReleases = data.map((release: any) => ({
tag_name: release.tag_name,
assets: release.assets.map((asset: any) => ({
browser_download_url: asset.browser_download_url,
id: asset.id,
name: asset.name,
})),
}))
setState(prevState => ({ ...prevState, releases: formattedReleases, step: InstallStepFromGitHub.setVersion }))
}
catch (error) {
Toast.notify({
type: 'error',
message: 'Failed to fetch repository release',
})
}
break
}
case InstallStepFromGitHub.setVersion:
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.setPackage }))
break
case InstallStepFromGitHub.setPackage:
handleInstall()
break
}
}
const handleFailed = useCallback((errorMsg?: string) => {
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.installFailed }))
if (errorMsg)
setErrorMsg(errorMsg)
}, [])
const handleBack = () => {
setState((prevState) => {
switch (prevState.step) {
case InstallStepFromGitHub.setVersion:
case InstallStepFromGitHub.selectPackage:
return { ...prevState, step: InstallStepFromGitHub.setUrl }
case InstallStepFromGitHub.setPackage:
return { ...prevState, step: InstallStepFromGitHub.setVersion }
case InstallStepFromGitHub.readyToInstall:
return { ...prevState, step: InstallStepFromGitHub.selectPackage }
default:
return prevState
}
})
}
return (
<Modal
isShow={true}
onClose={onClose}
className='flex min-w-[480px] p-0 flex-col items-start rounded-2xl border-[0.5px]
className='flex min-w-[560px] p-0 flex-col items-start rounded-2xl border-[0.5px]
border-components-panel-border bg-components-panel-bg shadows-shadow-xl'
closable
>
<div className='flex pt-6 pl-6 pb-3 pr-14 items-start gap-2 self-stretch'>
<div className='flex flex-col items-start gap-1 flex-grow'>
<div className='self-stretch text-text-primary title-2xl-semi-bold'>
{t('plugin.installFromGitHub.installPlugin')}
{getTitle()}
</div>
<div className='self-stretch text-text-tertiary system-xs-regular'>
{state.step !== InstallStepFromGitHub.installed && t('plugin.installFromGitHub.installNote')}
{!([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) && t('plugin.installFromGitHub.installNote')}
</div>
</div>
</div>
<div className={`flex px-6 py-3 flex-col justify-center items-start self-stretch ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`}>
{state.step === InstallStepFromGitHub.setUrl && (
<SetURL
repoUrl={state.repoUrl}
onChange={value => setState(prevState => ({ ...prevState, repoUrl: value }))}
onNext={handleNext}
onCancel={onClose}
/>
)}
{state.step === InstallStepFromGitHub.setVersion && (
<SetVersion
selectedVersion={state.selectedVersion}
versions={versions}
onSelect={item => setState(prevState => ({ ...prevState, selectedVersion: item.value as string }))}
onNext={handleNext}
onBack={handleBack}
/>
)}
{state.step === InstallStepFromGitHub.setPackage && (
<SetPackage
selectedPackage={state.selectedPackage}
packages={packages}
onSelect={item => setState(prevState => ({ ...prevState, selectedPackage: item.value as string }))}
onInstall={handleInstall}
onBack={handleBack}
/>
)}
{state.step === InstallStepFromGitHub.installed && (
<Installed
repoUrl={state.repoUrl}
selectedVersion={state.selectedVersion}
selectedPackage={state.selectedPackage}
onClose={onClose}
/>
)}
</div>
{([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step))
? <Installed
payload={manifest}
isFailed={[InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installFailed].includes(state.step)}
errMsg={errorMsg}
onCancel={onClose}
/>
: <div className={`flex px-6 py-3 flex-col justify-center items-start self-stretch ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`}>
{state.step === InstallStepFromGitHub.setUrl && (
<SetURL
repoUrl={state.repoUrl}
onChange={value => setState(prevState => ({ ...prevState, repoUrl: value }))}
onNext={handleUrlSubmit}
onCancel={onClose}
/>
)}
{state.step === InstallStepFromGitHub.selectPackage && (
<SelectPackage
updatePayload={updatePayload!}
repoUrl={state.repoUrl}
selectedVersion={state.selectedVersion}
versions={versions}
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: item.value as string }))}
selectedPackage={state.selectedPackage}
packages={packages}
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: item.value as string }))}
onUploaded={handleUploaded}
onFailed={handleUploadFail}
onBack={handleBack}
/>
)}
{state.step === InstallStepFromGitHub.readyToInstall && (
<Loaded
updatePayload={updatePayload!}
uniqueIdentifier={uniqueIdentifier!}
payload={manifest as any}
repoUrl={state.repoUrl}
selectedVersion={state.selectedVersion}
selectedPackage={state.selectedPackage}
onBack={handleBack}
onInstalled={handleInstalled}
onFailed={handleFailed}
/>
)}
</div>}
</Modal>
)
}

View File

@ -1,54 +0,0 @@
import React from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
type InstalledProps = {
repoUrl: string
selectedVersion: string
selectedPackage: string
onClose: () => void
}
const InfoRow = ({ label, value }: { label: string; value: string }) => (
<div className='flex items-center gap-3'>
<div className='flex-shrink-0 w-[72px] items-center gap-2'>
<div className='text-text-tertiary system-sm-medium truncate'>
{label}
</div>
</div>
<div className='flex-grow overflow-hidden'>
<div className='text-text-secondary text-ellipsis system-sm-medium'>
{value}
</div>
</div>
</div>
)
const Installed: React.FC<InstalledProps> = ({ repoUrl, selectedVersion, selectedPackage, onClose }) => {
const { t } = useTranslation()
return (
<>
<div className='text-text-secondary system-md-regular'>The plugin has been installed successfully.</div>
<div className='flex w-full p-4 flex-col justify-center items-start gap-2 rounded-2xl bg-background-section-burn'>
{[
{ label: t('plugin.installModal.labels.repository'), value: repoUrl },
{ label: t('plugin.installModal.labels.version'), value: selectedVersion },
{ label: t('plugin.installModal.labels.package'), value: selectedPackage },
].map(({ label, value }) => (
<InfoRow key={label} label={label} value={value} />
))}
</div>
<div className='flex justify-end items-center gap-2 self-stretch mt-4'>
<Button
variant='primary'
className='min-w-[72px]'
onClick={onClose}
>
{t('plugin.installModal.close')}
</Button>
</div>
</>
)
}
export default Installed

View File

@ -0,0 +1,119 @@
'use client'
import React from 'react'
import Button from '@/app/components/base/button'
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
import Card from '../../../card'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import { pluginManifestToCardPluginProps } from '../../utils'
import { useTranslation } from 'react-i18next'
import { installPackageFromGitHub, uninstallPlugin } from '@/service/plugins'
import { RiLoader2Line } from '@remixicon/react'
import { usePluginTasksStore } from '@/app/components/plugins/plugin-page/store'
import checkTaskStatus from '../../base/check-task-status'
import { parseGitHubUrl } from '../../utils'
type LoadedProps = {
updatePayload: UpdateFromGitHubPayload
uniqueIdentifier: string
payload: PluginDeclaration
repoUrl: string
selectedVersion: string
selectedPackage: string
onBack: () => void
onInstalled: () => void
onFailed: (message?: string) => void
}
const i18nPrefix = 'plugin.installModal'
const Loaded: React.FC<LoadedProps> = ({
updatePayload,
uniqueIdentifier,
payload,
repoUrl,
selectedVersion,
selectedPackage,
onBack,
onInstalled,
onFailed,
}) => {
const { t } = useTranslation()
const [isInstalling, setIsInstalling] = React.useState(false)
const setPluginTasksWithPolling = usePluginTasksStore(s => s.setPluginTasksWithPolling)
const { check } = checkTaskStatus()
const handleInstall = async () => {
if (isInstalling) return
setIsInstalling(true)
try {
const { owner, repo } = parseGitHubUrl(repoUrl)
const { all_installed: isInstalled, task_id: taskId } = await installPackageFromGitHub(
`${owner}/${repo}`,
selectedVersion,
selectedPackage,
uniqueIdentifier,
)
if (updatePayload && isInstalled)
await uninstallPlugin(updatePayload.originalPackageInfo.id)
if (isInstalled) {
onInstalled()
return
}
setPluginTasksWithPolling()
await check({
taskId,
pluginUniqueIdentifier: uniqueIdentifier,
})
onInstalled()
}
catch (e) {
if (typeof e === 'string') {
onFailed(e)
return
}
onFailed()
}
finally {
setIsInstalling(false)
}
}
return (
<>
<div className='text-text-secondary system-md-regular'>
<p>{t(`${i18nPrefix}.readyToInstall`)}</p>
</div>
<div className='flex p-2 items-start content-start gap-1 self-stretch flex-wrap rounded-2xl bg-background-section-burn'>
<Card
className='w-full'
payload={pluginManifestToCardPluginProps(payload)}
titleLeft={<Badge className='mx-1' size="s" state={BadgeState.Default}>{payload.version}</Badge>}
/>
</div>
<div className='flex justify-end items-center gap-2 self-stretch mt-4'>
{!isInstalling && (
<Button variant='secondary' className='min-w-[72px]' onClick={onBack}>
{t('plugin.installModal.back')}
</Button>
)}
<Button
variant='primary'
className='min-w-[72px] flex space-x-0.5'
onClick={handleInstall}
disabled={isInstalling}
>
{isInstalling && <RiLoader2Line className='w-4 h-4 animate-spin-slow' />}
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span>
</Button>
</div>
</>
)
}
export default Loaded

View File

@ -0,0 +1,126 @@
'use client'
import React from 'react'
import type { Item } from '@/app/components/base/select'
import { PortalSelect } from '@/app/components/base/select'
import Button from '@/app/components/base/button'
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
import { useTranslation } from 'react-i18next'
import { useGitHubUpload } from '../../hooks'
const i18nPrefix = 'plugin.installFromGitHub'
type SelectPackageProps = {
updatePayload: UpdateFromGitHubPayload
repoUrl: string
selectedVersion: string
versions: Item[]
onSelectVersion: (item: Item) => void
selectedPackage: string
packages: Item[]
onSelectPackage: (item: Item) => void
onUploaded: (result: {
uniqueIdentifier: string
manifest: PluginDeclaration
}) => void
onFailed: (errorMsg: string) => void
onBack: () => void
}
const SelectPackage: React.FC<SelectPackageProps> = ({
updatePayload,
repoUrl,
selectedVersion,
versions,
onSelectVersion,
selectedPackage,
packages,
onSelectPackage,
onUploaded,
onFailed,
onBack,
}) => {
const { t } = useTranslation()
const isEdit = Boolean(updatePayload)
const [isUploading, setIsUploading] = React.useState(false)
const { handleUpload } = useGitHubUpload()
const handleUploadPackage = async () => {
if (isUploading) return
setIsUploading(true)
try {
const repo = repoUrl.replace('https://github.com/', '')
await handleUpload(repo, selectedVersion, selectedPackage, (GitHubPackage) => {
onUploaded({
uniqueIdentifier: GitHubPackage.unique_identifier,
manifest: GitHubPackage.manifest,
})
})
}
catch (e: any) {
if (e.response?.message)
onFailed(e.response?.message)
else
onFailed(t(`${i18nPrefix}.uploadFailed`))
}
finally {
setIsUploading(false)
}
}
return (
<>
<label
htmlFor='version'
className='flex flex-col justify-center items-start self-stretch text-text-secondary'
>
<span className='system-sm-semibold'>{t(`${i18nPrefix}.selectVersion`)}</span>
</label>
<PortalSelect
value={selectedVersion}
onSelect={onSelectVersion}
items={versions}
installedValue={updatePayload?.originalPackageInfo.version}
placeholder={t(`${i18nPrefix}.selectVersionPlaceholder`) || ''}
popupClassName='w-[512px] z-[1001]'
/>
<label
htmlFor='package'
className='flex flex-col justify-center items-start self-stretch text-text-secondary'
>
<span className='system-sm-semibold'>{t(`${i18nPrefix}.selectPackage`)}</span>
</label>
<PortalSelect
value={selectedPackage}
onSelect={onSelectPackage}
items={packages}
readonly={!selectedVersion}
placeholder={t(`${i18nPrefix}.selectPackagePlaceholder`) || ''}
popupClassName='w-[512px] z-[1001]'
/>
<div className='flex justify-end items-center gap-2 self-stretch mt-4'>
{!isEdit
&& <Button
variant='secondary'
className='min-w-[72px]'
onClick={onBack}
disabled={isUploading}
>
{t('plugin.installModal.back')}
</Button>
}
<Button
variant='primary'
className='min-w-[72px]'
onClick={handleUploadPackage}
disabled={!selectedVersion || !selectedPackage || isUploading}
>
{t('plugin.installModal.next')}
</Button>
</div>
</>
)
}
export default SelectPackage

View File

@ -1,53 +0,0 @@
import React from 'react'
import type { Item } from '@/app/components/base/select'
import { PortalSelect } from '@/app/components/base/select'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
type SetPackageProps = {
selectedPackage: string
packages: Item[]
onSelect: (item: Item) => void
onInstall: () => void
onBack: () => void
}
const SetPackage: React.FC<SetPackageProps> = ({ selectedPackage, packages, onSelect, onInstall, onBack }) => {
const { t } = useTranslation()
return (
<>
<label
htmlFor='package'
className='flex flex-col justify-center items-start self-stretch text-text-secondary'
>
<span className='system-sm-semibold'>{t('plugin.installFromGitHub.selectPackage')}</span>
</label>
<PortalSelect
value={selectedPackage}
onSelect={onSelect}
items={packages}
placeholder={t('plugin.installFromGitHub.selectPackagePlaceholder') || ''}
popupClassName='w-[432px] z-[1001]'
/>
<div className='flex justify-end items-center gap-2 self-stretch mt-4'>
<Button
variant='secondary'
className='min-w-[72px]'
onClick={onBack}
>
{t('plugin.installModal.back')}
</Button>
<Button
variant='primary'
className='min-w-[72px]'
onClick={onInstall}
disabled={!selectedPackage}
>
{t('plugin.installModal.install')}
</Button>
</div>
</>
)
}
export default SetPackage

View File

@ -1,3 +1,5 @@
'use client'
import React from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'

View File

@ -1,53 +0,0 @@
import React from 'react'
import type { Item } from '@/app/components/base/select'
import { PortalSelect } from '@/app/components/base/select'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
type SetVersionProps = {
selectedVersion: string
versions: Item[]
onSelect: (item: Item) => void
onNext: () => void
onBack: () => void
}
const SetVersion: React.FC<SetVersionProps> = ({ selectedVersion, versions, onSelect, onNext, onBack }) => {
const { t } = useTranslation()
return (
<>
<label
htmlFor='version'
className='flex flex-col justify-center items-start self-stretch text-text-secondary'
>
<span className='system-sm-semibold'>{t('plugin.installFromGitHub.selectVersion')}</span>
</label>
<PortalSelect
value={selectedVersion}
onSelect={onSelect}
items={versions}
placeholder={t('plugin.installFromGitHub.selectVersionPlaceholder') || ''}
popupClassName='w-[432px] z-[1001]'
/>
<div className='flex justify-end items-center gap-2 self-stretch mt-4'>
<Button
variant='secondary'
className='min-w-[72px]'
onClick={onBack}
>
{t('plugin.installModal.back')}
</Button>
<Button
variant='primary'
className='min-w-[72px]'
onClick={onNext}
disabled={!selectedVersion}
>
{t('plugin.installModal.next')}
</Button>
</div>
</>
)
}
export default SetVersion

View File

@ -9,7 +9,7 @@ import Install from './steps/install'
import Installed from '../base/installed'
import { useTranslation } from 'react-i18next'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { usePluginPageContext } from '../../plugin-page/context'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
const i18nPrefix = 'plugin.installModal'
@ -29,7 +29,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
const [manifest, setManifest] = useState<PluginDeclaration | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const mutateInstalledPluginList = usePluginPageContext(v => v.mutateInstalledPluginList)
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const getTitle = useCallback(() => {
if (step === InstallStep.uploadFailed)
@ -40,7 +40,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
return t(`${i18nPrefix}.installFailed`)
return t(`${i18nPrefix}.installPlugin`)
}, [step])
}, [step, t])
const { getIconUrl } = useGetIcon()
@ -59,7 +59,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
icon,
})
setStep(InstallStep.readyToInstall)
}, [])
}, [getIconUrl])
const handleUploadFail = useCallback((errorMsg: string) => {
setErrorMsg(errorMsg)
@ -67,9 +67,9 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
}, [])
const handleInstalled = useCallback(() => {
mutateInstalledPluginList()
invalidateInstalledPluginList()
setStep(InstallStep.installed)
}, [mutateInstalledPluginList])
}, [invalidateInstalledPluginList])
const handleFailed = useCallback((errorMsg?: string) => {
setStep(InstallStep.installFailed)

View File

@ -10,6 +10,7 @@ import { RiLoader2Line } from '@remixicon/react'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import { installPackageFromLocal } from '@/service/plugins'
import checkTaskStatus from '../../base/check-task-status'
import { usePluginTasksStore } from '@/app/components/plugins/plugin-page/store'
const i18nPrefix = 'plugin.installModal'
@ -17,6 +18,7 @@ type Props = {
uniqueIdentifier: string
payload: PluginDeclaration
onCancel: () => void
onStartToInstall?: () => void
onInstalled: () => void
onFailed: (message?: string) => void
}
@ -25,6 +27,7 @@ const Installed: FC<Props> = ({
uniqueIdentifier,
payload,
onCancel,
onStartToInstall,
onInstalled,
onFailed,
}) => {
@ -40,9 +43,12 @@ const Installed: FC<Props> = ({
onCancel()
}
const setPluginTasksWithPolling = usePluginTasksStore(s => s.setPluginTasksWithPolling)
const handleInstall = async () => {
if (isInstalling) return
setIsInstalling(true)
onStartToInstall?.()
try {
const {
all_installed: isInstalled,
@ -52,6 +58,7 @@ const Installed: FC<Props> = ({
onInstalled()
return
}
setPluginTasksWithPolling()
await check({
taskId,
pluginUniqueIdentifier: uniqueIdentifier,

View File

@ -30,7 +30,7 @@ const Uploading: FC<Props> = ({
const handleUpload = async () => {
try {
const res = await uploadPackageFile(file)
// onUploaded(res)
onUploaded(res)
}
catch (e: any) {
if (e.response?.message) {

View File

@ -2,7 +2,7 @@
import React, { useCallback, useState } from 'react'
import Modal from '@/app/components/base/modal'
import type { PluginManifestInMarket } from '../../types'
import type { Plugin, PluginManifestInMarket } from '../../types'
import { InstallStep } from '../../types'
import Install from './steps/install'
import Installed from '../base/installed'
@ -12,7 +12,7 @@ const i18nPrefix = 'plugin.installModal'
type InstallFromMarketplaceProps = {
uniqueIdentifier: string
manifest: PluginManifestInMarket
manifest: PluginManifestInMarket | Plugin
onSuccess: () => void
onClose: () => void
}
@ -36,7 +36,7 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
if (step === InstallStep.installFailed)
return t(`${i18nPrefix}.installFailed`)
return t(`${i18nPrefix}.installPlugin`)
}, [step])
}, [step, t])
const handleInstalled = useCallback(() => {
setStep(InstallStep.installed)

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import React, { useMemo } from 'react'
import { RiInformation2Line } from '@remixicon/react'
import type { PluginManifestInMarket } from '../../../types'
import type { Plugin, PluginManifestInMarket } from '../../../types'
import Card from '../../../card'
import { pluginManifestInMarketToPluginProps } from '../../utils'
import Button from '@/app/components/base/button'
@ -16,8 +16,9 @@ const i18nPrefix = 'plugin.installModal'
type Props = {
uniqueIdentifier: string
payload: PluginManifestInMarket
payload: PluginManifestInMarket | Plugin
onCancel: () => void
onStartToInstall?: () => void
onInstalled: () => void
onFailed: (message?: string) => void
}
@ -26,6 +27,7 @@ const Installed: FC<Props> = ({
uniqueIdentifier,
payload,
onCancel,
onStartToInstall,
onInstalled,
onFailed,
}) => {
@ -43,6 +45,7 @@ const Installed: FC<Props> = ({
const handleInstall = async () => {
if (isInstalling) return
onStartToInstall?.()
setIsInstalling(true)
try {
@ -90,7 +93,7 @@ const Installed: FC<Props> = ({
</>
)
}</>)
}, [payload])
}, [payload.latest_version, supportCheckInstalled])
return (
<>
@ -101,7 +104,7 @@ const Installed: FC<Props> = ({
<div className='flex p-2 items-start content-start gap-1 self-stretch flex-wrap rounded-2xl bg-background-section-burn'>
<Card
className='w-full'
payload={pluginManifestInMarketToPluginProps(payload)}
payload={pluginManifestInMarketToPluginProps(payload as PluginManifestInMarket)}
titleLeft={versionInfo}
/>
</div>

View File

@ -1,12 +1,15 @@
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../types'
import type { GitHubUrlInfo } from '@/app/components/plugins/types'
export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaration): Plugin => {
return {
plugin_id: pluginManifest.plugin_unique_identifier,
type: pluginManifest.category,
category: pluginManifest.category,
name: pluginManifest.name,
version: pluginManifest.version,
latest_version: '',
latest_package_identifier: '',
org: pluginManifest.author,
label: pluginManifest.label,
brief: pluginManifest.description,
@ -18,16 +21,19 @@ export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaratio
endpoint: {
settings: [],
},
tags: [],
}
}
export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManifestInMarket): Plugin => {
return {
plugin_id: pluginManifest.plugin_unique_identifier,
type: pluginManifest.category,
category: pluginManifest.category,
name: pluginManifest.name,
version: pluginManifest.latest_version,
latest_version: pluginManifest.latest_version,
latest_package_identifier: '',
org: pluginManifest.org,
label: pluginManifest.label,
brief: pluginManifest.brief,
@ -39,5 +45,15 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife
endpoint: {
settings: [],
},
tags: [],
}
}
export const parseGitHubUrl = (url: string): GitHubUrlInfo => {
const match = url.match(/^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/?$/)
return match ? { isValid: true, owner: match[1], repo: match[2] } : { isValid: false }
}
export const convertRepoToUrl = (repo: string) => {
return repo ? `https://github.com/${repo}` : ''
}

View File

@ -100,6 +100,15 @@ export const MarketplaceContextProvider = ({
setSearchPluginText(text)
searchPluginTextRef.current = text
if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) {
queryMarketplaceCollectionsAndPlugins({
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
})
setPlugins(undefined)
return
}
queryPluginsWithDebounced({
query: text,
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
@ -107,12 +116,21 @@ export const MarketplaceContextProvider = ({
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
})
}, [queryPluginsWithDebounced])
}, [queryPluginsWithDebounced, queryMarketplaceCollectionsAndPlugins, setPlugins])
const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
setFilterPluginTags(tags)
filterPluginTagsRef.current = tags
if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) {
queryMarketplaceCollectionsAndPlugins({
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
})
setPlugins(undefined)
return
}
queryPlugins({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
@ -120,7 +138,7 @@ export const MarketplaceContextProvider = ({
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
})
}, [queryPlugins])
}, [queryPlugins, setPlugins, queryMarketplaceCollectionsAndPlugins])
const handleActivePluginTypeChange = useCallback((type: string) => {
setActivePluginType(type)
@ -130,7 +148,7 @@ export const MarketplaceContextProvider = ({
queryMarketplaceCollectionsAndPlugins({
category: type === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : type,
})
setPlugins([])
setPlugins(undefined)
return
}

View File

@ -1,25 +1,38 @@
const Description = () => {
import {
getLocaleOnServer,
useTranslation as translate,
} from '@/i18n/server'
type DescriptionProps = {
locale?: string
}
const Description = async ({
locale: localeFromProps,
}: DescriptionProps) => {
const localeDefault = getLocaleOnServer()
const { t } = await translate(localeFromProps || localeDefault, 'plugin')
return (
<>
<h1 className='mb-2 text-center title-4xl-semi-bold text-text-primary'>
<h1 className='shrink-0 mb-2 text-center title-4xl-semi-bold text-text-primary'>
Empower your AI development
</h1>
<h2 className='flex justify-center items-center text-center body-md-regular text-text-tertiary'>
<h2 className='shrink-0 flex justify-center items-center text-center body-md-regular text-text-tertiary'>
Discover
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
models
{t('category.models')}
</span>
,
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
tools
{t('category.tools')}
</span>
,
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
extensions
{t('category.extensions')}
</span>
and
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
bundles
{t('category.bundles')}
</span>
in Dify Marketplace
</h2>

View File

@ -4,7 +4,7 @@ import Line from './line'
const Empty = () => {
return (
<div
className='relative grid grid-cols-4 grid-rows-4 gap-3 p-2'
className='grow relative h-0 grid grid-cols-4 grid-rows-4 gap-3 p-2 overflow-hidden'
>
{
Array.from({ length: 16 }).map((_, index) => (

View File

@ -2,6 +2,7 @@ import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import type { Plugin } from '../types'
import type {
@ -13,6 +14,7 @@ import {
getMarketplaceCollectionsAndPlugins,
getMarketplacePlugins,
} from './utils'
import i18n from '@/i18n/i18next-config'
export const useMarketplaceCollectionsAndPlugins = () => {
const [isLoading, setIsLoading] = useState(false)
@ -40,7 +42,7 @@ export const useMarketplaceCollectionsAndPlugins = () => {
export const useMarketplacePlugins = () => {
const [isLoading, setIsLoading] = useState(false)
const [plugins, setPlugins] = useState<Plugin[]>([])
const [plugins, setPlugins] = useState<Plugin[]>()
const queryPlugins = useCallback(async (query: PluginsSearchParams) => {
setIsLoading(true)
@ -63,3 +65,14 @@ export const useMarketplacePlugins = () => {
setIsLoading,
}
}
export const useMixedTranslation = (localeFromOuter?: string) => {
let t = useTranslation().t
if (localeFromOuter)
t = i18n.getFixedT(localeFromOuter)
return {
t,
}
}

View File

@ -7,20 +7,23 @@ import ListWrapper from './list/list-wrapper'
import { getMarketplaceCollectionsAndPlugins } from './utils'
type MarketplaceProps = {
locale?: string
showInstallButton?: boolean
}
const Marketplace = async ({
locale,
showInstallButton = true,
}: MarketplaceProps) => {
const { marketplaceCollections, marketplaceCollectionPluginsMap } = await getMarketplaceCollectionsAndPlugins()
return (
<MarketplaceContextProvider>
<Description />
<Description locale={locale} />
<IntersectionLine />
<SearchBoxWrapper />
<PluginTypeSwitch />
<SearchBoxWrapper locale={locale} />
<PluginTypeSwitch locale={locale} />
<ListWrapper
locale={locale}
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}

View File

@ -1,17 +1,16 @@
import { useEffect } from 'react'
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
import { useMarketplaceContext } from '@/app/components/plugins/marketplace/context'
export const useScrollIntersection = (
anchorRef: React.RefObject<HTMLDivElement>,
) => {
const containerRef = usePluginPageContext(v => v.containerRef)
const intersected = useMarketplaceContext(v => v.intersected)
const setIntersected = useMarketplaceContext(v => v.setIntersected)
useEffect(() => {
const container = document.getElementById('marketplace-container')
let observer: IntersectionObserver | undefined
if (containerRef?.current && anchorRef.current) {
if (container && anchorRef.current) {
observer = new IntersectionObserver((entries) => {
const isIntersecting = entries[0].isIntersecting
@ -21,10 +20,10 @@ export const useScrollIntersection = (
if (!isIntersecting && intersected)
setIntersected(false)
}, {
root: containerRef.current,
root: container,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [containerRef, anchorRef, intersected, setIntersected])
}, [anchorRef, intersected, setIntersected])
}

View File

@ -1,26 +1,42 @@
'use client'
import { RiArrowRightUpLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import type { Plugin } from '@/app/components/plugins/types'
import { MARKETPLACE_URL_PREFIX } from '@/config'
import Button from '@/app/components/base/button'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useBoolean } from 'ahooks'
type CardWrapperProps = {
plugin: Plugin
showInstallButton?: boolean
locale?: string
}
const CardWrapper = ({
plugin,
showInstallButton,
locale,
}: CardWrapperProps) => {
const { t } = useTranslation()
const { t } = useMixedTranslation(locale)
const [isShowInstallFromMarketplace, {
setTrue: showInstallFromMarketplace,
setFalse: hideInstallFromMarketplace,
}] = useBoolean(false)
return (
<div className='group relative rounded-xl cursor-pointer'>
<div
className='group relative rounded-xl cursor-pointer'
onClick={() => {
if (!showInstallButton)
window.open(`${MARKETPLACE_URL_PREFIX}/plugin/${plugin.org}/${plugin.name}`)
}}
>
<Card
key={plugin.name}
payload={plugin}
locale={locale}
footer={
<CardMoreInfo
downloadCount={plugin.install_count}
@ -34,6 +50,7 @@ const CardWrapper = ({
<Button
variant='primary'
className='flex-1'
onClick={showInstallFromMarketplace}
>
{t('plugin.detailPanel.operation.install')}
</Button>
@ -48,6 +65,16 @@ const CardWrapper = ({
</div>
)
}
{
isShowInstallFromMarketplace && (
<InstallFromMarketplace
manifest={plugin as any}
uniqueIdentifier={plugin.latest_package_identifier}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)
}
</div>
)
}

View File

@ -10,12 +10,14 @@ type ListProps = {
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
plugins?: Plugin[]
showInstallButton?: boolean
locale?: string
}
const List = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
plugins,
showInstallButton,
locale,
}: ListProps) => {
return (
<>
@ -25,6 +27,7 @@ const List = ({
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
locale={locale}
/>
)
}
@ -37,6 +40,7 @@ const List = ({
key={plugin.name}
plugin={plugin}
showInstallButton={showInstallButton}
locale={locale}
/>
))
}

View File

@ -7,11 +7,13 @@ type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
locale?: string
}
const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
locale,
}: ListWithCollectionProps) => {
return (
<>
@ -30,6 +32,7 @@ const ListWithCollection = ({
key={plugin.name}
plugin={plugin}
showInstallButton={showInstallButton}
locale={locale}
/>
))
}

View File

@ -9,18 +9,20 @@ type ListWrapperProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
locale?: string
}
const ListWrapper = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
locale,
}: ListWrapperProps) => {
const plugins = useMarketplaceContext(v => v.plugins)
const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient)
const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient)
return (
<div className='px-12 py-2 bg-background-default-subtle'>
<div className='flex flex-col grow px-12 py-2 bg-background-default-subtle'>
{
plugins && (
<div className='flex items-center mb-4 pt-3'>
@ -35,6 +37,7 @@ const ListWrapper = ({
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
plugins={plugins}
showInstallButton={showInstallButton}
locale={locale}
/>
</div>
)

View File

@ -1,5 +1,4 @@
'use client'
import {
RiArchive2Line,
RiBrain2Line,
@ -8,6 +7,7 @@ import {
} from '@remixicon/react'
import { PluginType } from '../types'
import { useMarketplaceContext } from './context'
import { useMixedTranslation } from './hooks'
import cn from '@/utils/classnames'
export const PLUGIN_TYPE_SEARCH_MAP = {
@ -17,40 +17,47 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
extension: PluginType.extension,
bundle: 'bundle',
}
const options = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: 'All',
icon: null,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: 'Models',
icon: <RiBrain2Line className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: 'Tools',
icon: <RiHammerLine className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: 'Extensions',
icon: <RiPuzzle2Line className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: 'Bundles',
icon: <RiArchive2Line className='mr-1.5 w-4 h-4' />,
},
]
const PluginTypeSwitch = () => {
type PluginTypeSwitchProps = {
locale?: string
}
const PluginTypeSwitch = ({
locale,
}: PluginTypeSwitchProps) => {
const { t } = useMixedTranslation(locale)
const activePluginType = useMarketplaceContext(s => s.activePluginType)
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
const options = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: 'All',
icon: null,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('plugin.category.models'),
icon: <RiBrain2Line className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('plugin.category.tools'),
icon: <RiHammerLine className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('plugin.category.extensions'),
icon: <RiPuzzle2Line className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('plugin.category.bundles'),
icon: <RiArchive2Line className='mr-1.5 w-4 h-4' />,
},
]
return (
<div className={cn(
'sticky top-[60px] flex items-center justify-center py-3 bg-background-body space-x-2 z-10',
'sticky top-[60px] shrink-0 flex items-center justify-center py-3 bg-background-body space-x-2 z-10',
)}>
{
options.map(option => (

View File

@ -12,6 +12,7 @@ type SearchBoxProps = {
onTagsChange: (tags: string[]) => void
size?: 'small' | 'large'
placeholder?: string
locale?: string
}
const SearchBox = ({
search,
@ -20,7 +21,8 @@ const SearchBox = ({
tags,
onTagsChange,
size = 'small',
placeholder = 'Search tools...',
placeholder = '',
locale,
}: SearchBoxProps) => {
return (
<div
@ -35,10 +37,11 @@ const SearchBox = ({
tags={tags}
onTagsChange={onTagsChange}
size={size}
locale={locale}
/>
<div className='mx-1 w-[1px] h-3.5 bg-divider-regular'></div>
<div className='grow flex items-center p-1 pl-2'>
<div className='flex items-center mr-2 py-0.5 w-full'>
<div className='flex items-center mr-2 w-full'>
<input
className={cn(
'grow block outline-none appearance-none body-md-medium text-text-secondary bg-transparent',

View File

@ -1,9 +1,16 @@
'use client'
import { useMarketplaceContext } from '../context'
import { useMixedTranslation } from '../hooks'
import SearchBox from './index'
import cn from '@/utils/classnames'
const SearchBoxWrapper = () => {
type SearchBoxWrapperProps = {
locale?: string
}
const SearchBoxWrapper = ({
locale,
}: SearchBoxWrapperProps) => {
const { t } = useMixedTranslation(locale)
const intersected = useMarketplaceContext(v => v.intersected)
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
@ -13,7 +20,7 @@ const SearchBoxWrapper = () => {
return (
<SearchBox
inputClassName={cn(
'sticky top-3 mx-auto w-[640px]',
'sticky top-3 mx-auto w-[640px] shrink-0',
!intersected && 'w-[508px] transition-[width] duration-300',
)}
search={searchPluginText}
@ -21,6 +28,8 @@ const SearchBoxWrapper = () => {
tags={filterPluginTags}
onTagsChange={handleFilterPluginTagsChange}
size='large'
locale={locale}
placeholder={t('plugin.searchPlugins')}
/>
)
}

View File

@ -14,30 +14,26 @@ import {
import Checkbox from '@/app/components/base/checkbox'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input'
import { useTags } from '@/app/components/plugins/hooks'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type TagsFilterProps = {
tags: string[]
onTagsChange: (tags: string[]) => void
size: 'small' | 'large'
locale?: string
}
const TagsFilter = ({
tags,
onTagsChange,
size,
locale,
}: TagsFilterProps) => {
const { t } = useMixedTranslation(locale)
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const options = [
{
value: 'search',
text: 'Search',
},
{
value: 'image',
text: 'Image',
},
]
const filteredOptions = options.filter(option => option.text.toLowerCase().includes(searchText.toLowerCase()))
const { tags: options, tagsMap } = useTags(t)
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => {
if (tags.includes(id))
onTagsChange(tags.filter((tag: string) => tag !== id))
@ -73,10 +69,10 @@ const TagsFilter = ({
size === 'small' && 'px-0.5 py-1',
)}>
{
!selectedTagsLength && 'All Tags'
!selectedTagsLength && t('pluginTags.allTags')
}
{
!!selectedTagsLength && tags.slice(0, 2).join(',')
!!selectedTagsLength && tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',')
}
{
selectedTagsLength > 2 && (
@ -108,23 +104,23 @@ const TagsFilter = ({
showLeftIcon
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder='Search tags'
placeholder={t('pluginTags.searchTags') || ''}
/>
</div>
<div className='p-1 max-h-[448px] overflow-y-auto'>
{
filteredOptions.map(option => (
<div
key={option.value}
key={option.name}
className='flex items-center px-2 py-1.5 h-7 rounded-lg cursor-pointer hover:bg-state-base-hover'
onClick={() => handleCheck(option.value)}
onClick={() => handleCheck(option.name)}
>
<Checkbox
className='mr-1'
checked={tags.includes(option.value)}
checked={tags.includes(option.name)}
/>
<div className='px-1 system-sm-medium text-text-secondary'>
{option.text}
{option.label}
</div>
</div>
))

View File

@ -14,13 +14,13 @@ export const getMarketplaceCollectionsAndPlugins = async (query?: CollectionsAnd
let marketplaceCollections = [] as MarketplaceCollection[]
let marketplaceCollectionPluginsMap = {} as Record<string, Plugin[]>
try {
const marketplaceCollectionsData = await globalThis.fetch(`${MARKETPLACE_API_PREFIX}/collections`, { cache: 'no-store' })
const marketplaceCollectionsData = await globalThis.fetch(`${MARKETPLACE_API_PREFIX}/collections?page=1&page_size=100`, { cache: 'no-store' })
const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json()
marketplaceCollections = marketplaceCollectionsDataJson.data.collections
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
let url = `${MARKETPLACE_API_PREFIX}/collections/${collection.name}/plugins`
let url = `${MARKETPLACE_API_PREFIX}/collections/${collection.name}/plugins?page=1&page_size=100`
if (query?.category)
url += `?category=${query.category}`
url += `&category=${query.category}`
const marketplaceCollectionPluginsData = await globalThis.fetch(url, { cache: 'no-store' })
const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json()
const plugins = marketplaceCollectionPluginsDataJson.data.plugins.map((plugin: Plugin) => {

View File

@ -1,37 +1,102 @@
import React from 'react'
import React, { useState } from 'react'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
import { useAppContext } from '@/context/app-context'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
const ActionCard = () => {
return (
<div className='px-4 py-3 bg-components-panel-item-bg rounded-xl border-[0.5px] border-components-panel-border-subtle shadow-xs cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover'>
<div className='pb-0.5 text-text-secondary system-md-semibold'>Notion Page Search</div>
<div className='text-text-tertiary system-xs-regular line-clamp-2'>A tool for performing a Google SERP search and extracting snippets and webpages.Input should be a search query.</div>
</div>
)
}
import ToolItem from '@/app/components/tools/provider/tool-item'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import {
fetchBuiltInToolList,
fetchCollectionDetail,
removeBuiltInToolCredential,
updateBuiltInToolCredential,
} from '@/service/tools'
const ActionList = () => {
const { t } = useTranslation()
// TODO use tool-item add api in tool providers
const { isCurrentWorkspaceManager } = useAppContext()
const currentPluginDetail = usePluginPageContext(v => v.currentPluginDetail)
const { data: provider } = useSWR(
`builtin/${currentPluginDetail.plugin_id}/${currentPluginDetail.name}`,
fetchCollectionDetail,
)
const { data } = useSWR(
`${currentPluginDetail.plugin_id}/${currentPluginDetail.name}`,
fetchBuiltInToolList,
)
const [showSettingAuth, setShowSettingAuth] = useState(false)
const handleCredentialSettingUpdate = () => {}
if (!data || !provider)
return null
return (
<div className='px-4 pt-2 pb-4'>
<div className='mb-1 py-1'>
<div className='mb-1 h-6 flex items-center justify-between text-text-secondary system-sm-semibold-uppercase'>
{t('plugin.detailPanel.actionNum', { num: 3 })}
<Button variant='secondary' size='small'>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
{t('plugin.detailPanel.actionNum', { num: data.length })}
{provider.is_team_authorization && provider.allow_delete && (
<Button
variant='secondary'
size='small'
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div>
<Button variant='primary' className='w-full'>{t('tools.auth.unauthorized')}</Button>
{!provider.is_team_authorization && provider.allow_delete && (
<Button
variant='primary'
className='w-full'
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>{t('tools.auth.unauthorized')}</Button>
)}
</div>
<div className='flex flex-col gap-2'>
<ActionCard />
<ActionCard />
<ActionCard />
{data.map(tool => (
<ToolItem
key={`${currentPluginDetail.plugin_id}${tool.name}`}
disabled={false}
collection={provider}
tool={tool}
isBuiltIn={true}
isModel={false}
/>
))}
</div>
{showSettingAuth && (
<ConfigCredential
collection={provider}
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential(provider.name, value)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
handleCredentialSettingUpdate()
setShowSettingAuth(false)
}}
onRemove={async () => {
await removeBuiltInToolCredential(provider.name)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
handleCredentialSettingUpdate()
setShowSettingAuth(false)
}}
/>
)}
</div>
)
}

View File

@ -1,6 +1,5 @@
import React, { useMemo } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useBoolean } from 'ahooks'
import {
RiBugLine,
@ -23,7 +22,9 @@ import Confirm from '@/app/components/base/confirm'
import Tooltip from '@/app/components/base/tooltip'
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
import { Github } from '@/app/components/base/icons/src/public/common'
import I18n from '@/context/i18n'
import { uninstallPlugin } from '@/service/plugins'
import { useGetLanguage } from '@/context/i18n'
import { API_PREFIX, MARKETPLACE_URL_PREFIX } from '@/config'
import cn from '@/utils/classnames'
const i18nPrefix = 'plugin.action'
@ -40,16 +41,25 @@ const DetailHeader = ({
onDelete,
}: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const locale = useGetLanguage()
const {
installation_id,
source,
tenant_id,
version,
latest_version,
meta,
} = detail
const { author, name, label, description, icon, verified } = detail.declaration
const isFromGitHub = source === PluginSource.github
// Only plugin installed from GitHub need to check if it's the new version
const hasNewVersion = useMemo(() => {
if (!detail)
return false
return false
// return pluginDetail.latest_version !== pluginDetail.version
}, [detail])
return source === PluginSource.github && latest_version !== version
}, [source, latest_version, version])
const handleUpdate = () => {}
// #plugin TODO# update plugin
const handleUpdate = () => { }
const [isShowPluginInfo, {
setTrue: showPluginInfo,
@ -61,19 +71,37 @@ const DetailHeader = ({
setFalse: hideDeleteConfirm,
}] = useBoolean(false)
const usedInApps = 3
const [deleting, {
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const handleDelete = useCallback(async () => {
showDeleting()
const res = await uninstallPlugin(installation_id)
hideDeleting()
if (res.success) {
hideDeleteConfirm()
onDelete()
}
}, [hideDeleteConfirm, hideDeleting, installation_id, showDeleting, onDelete])
// #plugin TODO# used in apps
// const usedInApps = 3
return (
<div className={cn('shrink-0 p-4 pb-3 border-b border-divider-subtle bg-components-panel-bg')}>
<div className="flex">
<Icon src={detail.declaration.icon} />
<div className='overflow-hidden border-components-panel-border-subtle border rounded-xl'>
<Icon src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`} />
</div>
<div className="ml-3 w-0 grow">
<div className="flex items-center h-5">
<Title title={detail.declaration.label[locale]} />
{detail.declaration.verified && <RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" />}
<Title title={label[locale]} />
{verified && <RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" />}
<Badge
className='mx-1'
text={detail.version}
text={version}
hasRedCornerMark={hasNewVersion}
/>
{hasNewVersion && (
@ -81,32 +109,31 @@ const DetailHeader = ({
)}
</div>
<div className='mb-1 flex justify-between items-center h-4'>
<div className='flex items-center'>
<div className='mt-0.5 flex items-center'>
<OrgInfo
className="mt-0.5"
packageNameClassName='w-auto'
orgName={detail.declaration.author}
packageName={detail.declaration.name}
orgName={author}
packageName={name}
/>
<div className='ml-1 mr-0.5 text-text-quaternary system-xs-regular'>·</div>
{detail.source === PluginSource.marketplace && (
<Tooltip popupContent={t('plugin.detailPanel.categoryTip.marketplace')} >
<BoxSparkleFill className='w-3.5 h-3.5 text-text-tertiary hover:text-text-accent' />
<div><BoxSparkleFill className='w-3.5 h-3.5 text-text-tertiary hover:text-text-accent' /></div>
</Tooltip>
)}
{detail.source === PluginSource.github && (
<Tooltip popupContent={t('plugin.detailPanel.categoryTip.github')} >
<Github className='w-3.5 h-3.5 text-text-secondary hover:text-text-primary' />
<div><Github className='w-3.5 h-3.5 text-text-secondary hover:text-text-primary' /></div>
</Tooltip>
)}
{detail.source === PluginSource.local && (
<Tooltip popupContent={t('plugin.detailPanel.categoryTip.local')} >
<RiHardDrive3Line className='w-3.5 h-3.5 text-text-tertiary' />
<div><RiHardDrive3Line className='w-3.5 h-3.5 text-text-tertiary' /></div>
</Tooltip>
)}
{detail.source === PluginSource.debugging && (
<Tooltip popupContent={t('plugin.detailPanel.categoryTip.debugging')} >
<RiBugLine className='w-3.5 h-3.5 text-text-tertiary hover:text-text-warning' />
<div><RiBugLine className='w-3.5 h-3.5 text-text-tertiary hover:text-text-warning' /></div>
</Tooltip>
)}
</div>
@ -115,19 +142,21 @@ const DetailHeader = ({
<div className='flex gap-1'>
<OperationDropdown
onInfo={showPluginInfo}
onCheckVersion={handleUpdate}
onRemove={showDeleteConfirm}
detailUrl={`${MARKETPLACE_URL_PREFIX}/plugin/${author}/${name}`}
/>
<ActionButton onClick={onHide}>
<RiCloseLine className='w-4 h-4' />
</ActionButton>
</div>
</div>
<Description className='mt-3' text={detail.declaration.description[locale]} descriptionLineRows={2}></Description>
<Description className='mt-3' text={description[locale]} descriptionLineRows={2}></Description>
{isShowPluginInfo && (
<PluginInfo
repository={detail.meta?.repo}
release={detail.version}
packageName={detail.meta?.package}
repository={isFromGitHub ? meta?.repo : ''}
release={version}
packageName={meta?.package || ''}
onHide={hidePluginInfo}
/>
)}
@ -137,12 +166,14 @@ const DetailHeader = ({
title={t(`${i18nPrefix}.delete`)}
content={
<div>
{t(`${i18nPrefix}.deleteContentLeft`)}<span className='system-md-semibold'>{detail.declaration.label[locale]}</span>{t(`${i18nPrefix}.deleteContentRight`)}<br />
{usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })}
{t(`${i18nPrefix}.deleteContentLeft`)}<span className='system-md-semibold'>{label[locale]}</span>{t(`${i18nPrefix}.deleteContentRight`)}<br />
{/* {usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })} */}
</div>
}
onCancel={hideDeleteConfirm}
onConfirm={onDelete}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
</div>

View File

@ -1,30 +1,35 @@
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useBoolean } from 'ahooks'
import { RiAddLine } from '@remixicon/react'
import type { EndpointListItem, PluginEndpointDeclaration } from '../types'
import EndpointModal from './endpoint-modal'
import EndpointCard from './endpoint-card'
import { toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
import {
createEndpoint,
fetchEndpointList,
} from '@/service/plugins'
type Props = {
pluginUniqueID: string
declaration: PluginEndpointDeclaration
list: EndpointListItem[]
}
const EndpointList = ({
pluginUniqueID,
declaration,
list,
}: Props) => {
const EndpointList = () => {
const { t } = useTranslation()
const pluginDetail = usePluginPageContext(v => v.currentPluginDetail)
const pluginUniqueID = pluginDetail.plugin_unique_identifier
const declaration = pluginDetail.declaration.endpoint
const { data } = useSWR(
{
url: '/workspaces/current/endpoints/list/plugin',
params: {
plugin_id: pluginDetail.plugin_id,
page: 1,
page_size: 100,
},
},
fetchEndpointList,
)
const [isShowEndpointModal, {
setTrue: showEndpointModal,
setFalse: hideEndpointModal,
@ -50,6 +55,9 @@ const EndpointList = ({
}
}
if (!data)
return null
return (
<div className='px-4 py-2 border-t border-divider-subtle'>
<div className='mb-1 h-6 flex items-center justify-between text-text-secondary system-sm-semibold-uppercase'>
@ -65,11 +73,11 @@ const EndpointList = ({
<RiAddLine className='w-4 h-4' />
</ActionButton>
</div>
{list.length === 0 && (
{data.endpoints.length === 0 && (
<div className='mb-1 p-3 flex justify-center rounded-[10px] bg-background-section text-text-tertiary system-xs-regular'>{t('plugin.detailPanel.endpointsEmpty')}</div>
)}
<div className='flex flex-col gap-2'>
{list.map((item, index) => (
{data.endpoints.map((item, index) => (
<EndpointCard
key={index}
data={item}

View File

@ -1,29 +1,25 @@
'use client'
import React from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import type { EndpointListItem, PluginDetail } from '../types'
import DetailHeader from './detail-header'
import EndpointList from './endpoint-list'
import ActionList from './action-list'
import ModelList from './model-list'
import Drawer from '@/app/components/base/drawer'
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
import cn from '@/utils/classnames'
type Props = {
pluginDetail: PluginDetail | undefined
endpointList: EndpointListItem[]
onHide: () => void
onDelete: () => void
}
const PluginDetailPanel: FC<Props> = ({
pluginDetail,
endpointList = [],
onHide,
onDelete,
}) => {
const { t } = useTranslation()
const pluginDetail = usePluginPageContext(v => v.currentPluginDetail)
const setCurrentPluginDetail = usePluginPageContext(v => v.setCurrentPluginDetail)
const handleDelete = () => {}
const handleHide = () => setCurrentPluginDetail(undefined)
if (!pluginDetail)
return null
@ -32,7 +28,7 @@ const PluginDetailPanel: FC<Props> = ({
<Drawer
isOpen={!!pluginDetail}
clickOutsideNotOpen={false}
onClose={onHide}
onClose={handleHide}
footer={null}
mask={false}
positionCenter={false}
@ -42,17 +38,11 @@ const PluginDetailPanel: FC<Props> = ({
<>
<DetailHeader
detail={pluginDetail}
onHide={onHide}
onDelete={handleDelete}
onHide={handleHide}
onDelete={onDelete}
/>
<div className='grow overflow-y-auto'>
{!!pluginDetail.declaration.endpoint && (
<EndpointList
pluginUniqueID={pluginDetail.plugin_unique_identifier}
list={endpointList}
declaration={pluginDetail.declaration.endpoint}
/>
)}
{!!pluginDetail.declaration.endpoint && <EndpointList />}
{!!pluginDetail.declaration.tool && <ActionList />}
{!!pluginDetail.declaration.model && <ModelList />}
</div>

View File

@ -1,28 +1,43 @@
import React from 'react'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
// import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
// import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
import { fetchModelProviderModelList } from '@/service/common'
const ModelList = () => {
const { t } = useTranslation()
const currentPluginDetail = usePluginPageContext(v => v.currentPluginDetail)
const { data: res } = useSWR(
`/workspaces/current/model-providers/${currentPluginDetail.plugin_id}/${currentPluginDetail.name}/models`,
fetchModelProviderModelList,
)
if (!res)
return null
return (
<div className='px-4 py-2'>
<div className='mb-1 h-6 flex items-center text-text-secondary system-sm-semibold-uppercase'>{t('plugin.detailPanel.modelNum', { num: 3 })}</div>
<div className='mb-1 h-6 flex items-center text-text-secondary system-sm-semibold-uppercase'>{t('plugin.detailPanel.modelNum', { num: res.data.length })}</div>
<div className='h-8 flex items-center'>
{/* <ModelIcon
className='shrink-0 mr-2'
provider={provider}
modelName={model.model}
/>
<ModelName
className='grow text-sm font-normal text-gray-900'
modelItem={model}
showModelType
showMode
showContextSize
>
</ModelName> */}
{res.data.map(model => (
<div key={model.model} className='h-6 py-1 flex items-center'>
<ModelIcon
className='shrink-0 mr-2'
provider={currentPluginDetail.declaration.model}
modelName={model.model}
/>
<ModelName
className='grow text-text-secondary system-md-regular'
modelItem={model}
showModelType
showMode
showContextSize
/>
</div>
))}
</div>
</div>
)

View File

@ -14,12 +14,16 @@ import cn from '@/utils/classnames'
type Props = {
onInfo: () => void
onCheckVersion: () => void
onRemove: () => void
detailUrl: string
}
const OperationDropdown: FC<Props> = ({
onInfo,
onCheckVersion,
onRemove,
detailUrl,
}) => {
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
@ -44,22 +48,40 @@ const OperationDropdown: FC<Props> = ({
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<ActionButton className={cn(open && 'bg-state-base-hover')}>
<RiMoreFill className='w-4 h-4' />
</ActionButton>
<div>
<ActionButton className={cn(open && 'bg-state-base-hover')}>
<RiMoreFill className='w-4 h-4' />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='w-[160px] p-1 bg-components-panel-bg-blur rounded-xl border-[0.5px] border-components-panel-border shadow-lg'>
<div onClick={onInfo} className='px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'>{t('plugin.detailPanel.operation.info')}</div>
{/* ##plugin TODO## check update */}
<div className='px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'>{t('plugin.detailPanel.operation.checkUpdate')}</div>
{/* ##plugin TODO## router action */}
<div className='flex items-center px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'>
<div className='grow'>{t('plugin.detailPanel.operation.viewDetail')}</div>
<div
onClick={() => {
onInfo()
handleTrigger()
}}
className='px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'
>{t('plugin.detailPanel.operation.info')}</div>
<div
onClick={() => {
onCheckVersion()
handleTrigger()
}}
className='px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'
>{t('plugin.detailPanel.operation.checkUpdate')}</div>
<a href={detailUrl} target='_blank' className='flex items-center px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'>
<span className='grow'>{t('plugin.detailPanel.operation.viewDetail')}</span>
<RiArrowRightUpLine className='shrink-0 w-3.5 h-3.5 text-text-tertiary' />
</div>
</a>
<div className='my-1 h-px bg-divider-subtle'></div>
<div onClick={onRemove} className='px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'>{t('plugin.detailPanel.operation.remove')}</div>
<div
onClick={() => {
onRemove()
handleTrigger()
}}
className='px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:text-text-destructive hover:bg-state-destructive-hover'
>{t('plugin.detailPanel.operation.remove')}</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import type { MetaData } from '../types'
import { type MetaData, PluginSource } from '../types'
import { RiDeleteBinLine, RiInformation2Line, RiLoopLeftLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
@ -10,23 +10,31 @@ import ActionButton from '../../base/action-button'
import Tooltip from '../../base/tooltip'
import Confirm from '../../base/confirm'
import { uninstallPlugin } from '@/service/plugins'
import { usePluginPageContext } from '../plugin-page/context'
import { useGitHubReleases } from '../install-plugin/hooks'
import { compareVersion, getLatestVersion } from '@/utils/semver'
import Toast from '@/app/components/base/toast'
import { useModalContext } from '@/context/modal-context'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
const i18nPrefix = 'plugin.action'
type Props = {
pluginId: string
author: string
installationId: string
pluginName: string
version: string
usedInApps: number
isShowFetchNewVersion: boolean
isShowInfo: boolean
isShowDelete: boolean
onDelete: () => void
meta: MetaData
meta?: MetaData
}
const Action: FC<Props> = ({
pluginId,
author,
installationId,
pluginName,
version,
isShowFetchNewVersion,
isShowInfo,
isShowDelete,
@ -38,13 +46,54 @@ const Action: FC<Props> = ({
setTrue: showPluginInfo,
setFalse: hidePluginInfo,
}] = useBoolean(false)
const mutateInstalledPluginList = usePluginPageContext(v => v.mutateInstalledPluginList)
const [deleting, {
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const { fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const handleFetchNewVersion = () => { }
const handleFetchNewVersion = async () => {
try {
const fetchedReleases = await fetchReleases(author, pluginName)
if (fetchedReleases.length === 0)
return
const versions = fetchedReleases.map(release => release.tag_name)
const latestVersion = getLatestVersion(versions)
if (compareVersion(latestVersion, version) === 1) {
setShowUpdatePluginModal({
onSaveCallback: () => {
invalidateInstalledPluginList()
},
payload: {
type: PluginSource.github,
github: {
originalPackageInfo: {
id: installationId,
repo: meta!.repo,
version: meta!.version,
package: meta!.package,
releases: fetchedReleases,
},
},
},
})
}
else {
Toast.notify({
type: 'info',
message: 'No new version available',
})
}
}
catch {
Toast.notify({
type: 'error',
message: 'Failed to compare versions',
})
}
}
const [isShowDeleteConfirm, {
setTrue: showDeleteConfirm,
@ -53,14 +102,13 @@ const Action: FC<Props> = ({
const handleDelete = useCallback(async () => {
showDeleting()
const res = await uninstallPlugin(pluginId)
const res = await uninstallPlugin(installationId)
hideDeleting()
if (res.success) {
hideDeleteConfirm()
mutateInstalledPluginList()
onDelete()
}
}, [pluginId, onDelete])
}, [installationId, onDelete])
return (
<div className='flex space-x-1'>
{/* Only plugin installed from GitHub need to check if it's the new version */}
@ -99,9 +147,9 @@ const Action: FC<Props> = ({
{isShowPluginInfo && (
<PluginInfo
repository={meta.repo}
release={meta.version}
packageName={meta.package}
repository={meta!.repo}
release={meta!.version}
packageName={meta!.package}
onHide={hidePluginInfo}
/>
)}

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import { useContext } from 'use-context-selector'
import {
RiArrowRightUpLine,
RiBugLine,
@ -10,29 +9,34 @@ import {
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { usePluginPageContext } from '../plugin-page/context'
import { Github } from '../../base/icons/src/public/common'
import Badge from '../../base/badge'
import { type InstalledPlugin, PluginSource } from '../types'
import { type PluginDetail, PluginSource } from '../types'
import CornerMark from '../card/base/corner-mark'
import Description from '../card/base/description'
import OrgInfo from '../card/base/org-info'
import Title from '../card/base/title'
import Action from './action'
import cn from '@/utils/classnames'
import I18n from '@/context/i18n'
import { API_PREFIX, MARKETPLACE_URL_PREFIX } from '@/config'
import { useLanguage } from '../../header/account-setting/model-provider-page/hooks'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
type Props = {
className?: string
plugin: InstalledPlugin
plugin: PluginDetail
}
const PluginItem: FC<Props> = ({
className,
plugin,
}) => {
const { locale } = useContext(I18n)
const locale = useLanguage()
const { t } = useTranslation()
const currentPluginDetail = usePluginPageContext(v => v.currentPluginDetail)
const setCurrentPluginDetail = usePluginPageContext(v => v.setCurrentPluginDetail)
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const {
source,
@ -40,27 +44,24 @@ const PluginItem: FC<Props> = ({
installation_id,
endpoints_active,
meta,
plugin_id,
version,
latest_version,
} = plugin
const { category, author, name, label, description, icon, verified } = plugin.declaration
// Only plugin installed from GitHub need to check if it's the new version
const hasNewVersion = useMemo(() => {
return source === PluginSource.github && latest_version !== version
}, [source, latest_version, version])
const orgName = useMemo(() => {
return [PluginSource.github, PluginSource.marketplace].includes(source) ? author : ''
}, [source, author])
const tLocale = useMemo(() => {
return locale.replace('-', '_')
}, [locale])
return (
<div className={`p-1 ${source === PluginSource.debugging
? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]'
: 'bg-background-section-burn'}
rounded-xl`}
<div
className={cn(
'p-1 rounded-xl border-[1.5px] border-background-section-burn',
currentPluginDetail?.plugin_id === plugin_id && 'border-components-option-card-option-selected-border',
source === PluginSource.debugging
? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]'
: 'bg-background-section-burn',
)}
onClick={() => setCurrentPluginDetail(plugin)}
>
<div className={cn('relative p-4 pb-3 border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg hover-bg-components-panel-on-panel-item-bg rounded-xl shadow-xs', className)}>
<CornerMark text={category} />
@ -68,28 +69,35 @@ const PluginItem: FC<Props> = ({
<div className="flex">
<div className='flex items-center justify-center w-10 h-10 overflow-hidden border-components-panel-border-subtle border-[1px] rounded-xl'>
<img
className='w-full h-full'
src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`}
alt={`plugin-${installation_id}-logo`}
/>
</div>
<div className="ml-3 w-0 grow">
<div className="flex items-center h-5">
<Title title={label[tLocale]} />
<Title title={label[locale]} />
{verified && <RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" />}
<Badge className='ml-1' text={plugin.version} hasRedCornerMark={hasNewVersion} />
<Badge className='ml-1' text={plugin.version} />
</div>
<div className='flex items-center justify-between'>
<Description text={description[tLocale]} descriptionLineRows={1}></Description>
<Action
pluginId={installation_id}
pluginName={label[tLocale]}
usedInApps={5}
isShowFetchNewVersion={hasNewVersion}
isShowInfo={source === PluginSource.github}
isShowDelete
meta={meta}
onDelete={() => {}}
/>
<Description text={description[locale]} descriptionLineRows={1}></Description>
<div onClick={e => e.stopPropagation()}>
<Action
installationId={installation_id}
author={author}
pluginName={name}
version={version}
usedInApps={5}
isShowFetchNewVersion={source === PluginSource.github}
isShowInfo={source === PluginSource.github}
isShowDelete
meta={meta}
onDelete={() => {
invalidateInstalledPluginList()
}}
/>
</div>
</div>
</div>
</div>
@ -112,7 +120,7 @@ const PluginItem: FC<Props> = ({
<div className='flex items-center'>
{source === PluginSource.github
&& <>
<a href={meta.repo} target='_blank' className='flex items-center gap-1'>
<a href={`https://github.com/${meta!.repo}`} target='_blank' className='flex items-center gap-1'>
<div className='text-text-tertiary system-2xs-medium-uppercase'>{t('plugin.from')}</div>
<div className='flex items-center space-x-0.5 text-text-secondary'>
<Github className='w-3 h-3' />

View File

@ -2,6 +2,7 @@
import type { ReactNode } from 'react'
import {
useMemo,
useRef,
useState,
} from 'react'
@ -9,20 +10,24 @@ import {
createContext,
useContextSelector,
} from 'use-context-selector'
import type { InstalledPlugin, Permissions } from '../types'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import type { Permissions, PluginDetail } from '../types'
import type { FilterState } from './filter-management'
import { PermissionType } from '../types'
import { fetchInstalledPluginList } from '@/service/plugins'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
export type PluginPageContextValue = {
containerRef: React.RefObject<HTMLDivElement>
permissions: Permissions
setPermissions: (permissions: PluginPageContextValue['permissions']) => void
installedPluginList: InstalledPlugin[]
mutateInstalledPluginList: () => void
currentPluginDetail: PluginDetail | undefined
setCurrentPluginDetail: (plugin: PluginDetail) => void
filters: FilterState
setFilters: (filter: FilterState) => void
activeTab: string
setActiveTab: (tab: string) => void
options: Array<{ value: string, text: string }>
}
export const PluginPageContext = createContext<PluginPageContextValue>({
@ -31,15 +36,18 @@ export const PluginPageContext = createContext<PluginPageContextValue>({
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
setPermissions: () => { },
installedPluginList: [],
mutateInstalledPluginList: () => {},
setPermissions: () => {},
currentPluginDetail: undefined,
setCurrentPluginDetail: () => {},
filters: {
categories: [],
tags: [],
searchQuery: '',
},
setFilters: () => {},
activeTab: '',
setActiveTab: () => {},
options: [],
})
type PluginPageContextProviderProps = {
@ -53,6 +61,7 @@ export function usePluginPageContext(selector: (value: PluginPageContextValue) =
export const PluginPageContextProvider = ({
children,
}: PluginPageContextProviderProps) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const [permissions, setPermissions] = useState<PluginPageContextValue['permissions']>({
install_permission: PermissionType.noOne,
@ -63,7 +72,22 @@ export const PluginPageContextProvider = ({
tags: [],
searchQuery: '',
})
const { data, mutate: mutateInstalledPluginList } = useSWR({ url: '/workspaces/current/plugin/list' }, fetchInstalledPluginList)
const [currentPluginDetail, setCurrentPluginDetail] = useState<PluginDetail | undefined>()
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
const options = useMemo(() => {
return [
{ value: 'plugins', text: t('common.menus.plugins') },
...(
enable_marketplace
? [{ value: 'discover', text: 'Explore Marketplace' }]
: []
),
]
}, [t, enable_marketplace])
const [activeTab, setActiveTab] = useTabSearchParams({
defaultTab: options[0].value,
})
return (
<PluginPageContext.Provider
@ -71,10 +95,13 @@ export const PluginPageContextProvider = ({
containerRef,
permissions,
setPermissions,
installedPluginList: data?.plugins || [],
mutateInstalledPluginList,
currentPluginDetail,
setCurrentPluginDetail,
filters,
setFilters,
activeTab,
setActiveTab,
options,
}}
>
{children}

View File

@ -0,0 +1,118 @@
import React, { useMemo, useRef, useState } from 'react'
import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { FileZip } from '@/app/components/base/icons/src/vender/solid/files'
import { Github } from '@/app/components/base/icons/src/vender/solid/general'
import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github'
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
import { usePluginPageContext } from '../context'
import { Group } from '@/app/components/base/icons/src/vender/other'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import Line from '../../marketplace/empty/line'
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
const Empty = () => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedAction, setSelectedAction] = useState<string | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
setSelectedFile(file)
setSelectedAction('local')
}
}
const filters = usePluginPageContext(v => v.filters)
const { data: pluginList } = useInstalledPluginList()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const text = useMemo(() => {
if (pluginList?.plugins.length === 0)
return 'No plugins installed'
if (filters.categories.length > 0 || filters.tags.length > 0 || filters.searchQuery)
return 'No plugins found'
}, [pluginList, filters])
return (
<div className='grow w-full relative z-0'>
{/* skeleton */}
<div className='h-full w-full px-12 absolute top-0 grid grid-cols-2 gap-2 overflow-hidden z-10'>
{Array.from({ length: 20 }).fill(0).map((_, i) => (
<div key={i} className='h-[100px] bg-components-card-bg rounded-xl'/>
))}
</div>
{/* mask */}
<div className='h-full w-full absolute z-20 bg-gradient-to-b from-background-gradient-mask-transparent to-white'/>
<div className='flex items-center justify-center h-full relative z-30'>
<div className='flex flex-col items-center gap-y-3'>
<div className='relative -z-10 flex items-center justify-center w-[52px] h-[52px] rounded-xl
bg-components-card-bg border-[1px] border-dashed border-divider-deep shadow-xl shadow-shadow-shadow-5'>
<Group className='text-text-tertiary w-5 h-5' />
<Line className='absolute -right-[1px] top-1/2 -translate-y-1/2' />
<Line className='absolute -left-[1px] top-1/2 -translate-y-1/2' />
<Line className='absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 rotate-90' />
<Line className='absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 rotate-90' />
</div>
<div className='text-text-tertiary text-sm font-normal'>
{text}
</div>
<div className='flex flex-col w-[240px]'>
<input
type='file'
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
accept='.difypkg'
/>
<div className='w-full flex flex-col gap-y-1'>
{[
...(
(enable_marketplace || true)
? [{ icon: MagicBox, text: 'Marketplace', action: 'marketplace' }]
: []
),
{ icon: Github, text: 'GitHub', action: 'github' },
{ icon: FileZip, text: 'Local Package File', action: 'local' },
].map(({ icon: Icon, text, action }) => (
<div
key={action}
className='flex items-center px-3 py-2 gap-x-1 rounded-lg bg-components-button-secondary-bg
hover:bg-state-base-hover cursor-pointer border-[0.5px] shadow-shadow-shadow-3 shadow-xs'
onClick={() => {
if (action === 'local')
fileInputRef.current?.click()
else if (action === 'marketplace')
setActiveTab('discover')
else
setSelectedAction(action)
}}
>
<Icon className="w-4 h-4 text-text-tertiary" />
<span className='text-text-secondary system-md-regular'>{`Install from ${text}`}</span>
</div>
))}
</div>
</div>
</div>
{selectedAction === 'github' && <InstallFromGitHub
onSuccess={() => { invalidateInstalledPluginList() }}
onClose={() => setSelectedAction(null)}
/>}
{selectedAction === 'local' && selectedFile
&& (<InstallFromLocalPackage
file={selectedFile}
onClose={() => setSelectedAction(null)}
onSuccess={() => { }}
/>
)
}
</div>
</div>
)
}
Empty.displayName = 'Empty'
export default React.memo(Empty)

View File

@ -87,7 +87,12 @@ const CategoriesFilter = ({
!!selectedTagsLength && (
<RiCloseCircleFill
className='w-4 h-4 text-text-quaternary cursor-pointer'
onClick={() => onChange([])}
onClick={
(e) => {
e.stopPropagation()
onChange([])
}
}
/>
)
}

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react'
import CategoriesFilter from './category-filter'
import TagFilter from './tag-filter'
import SearchBox from './search-box'
import { usePluginPageContext } from '../context'
export type FilterState = {
categories: string[]
@ -14,11 +15,8 @@ type FilterManagementProps = {
}
const FilterManagement: React.FC<FilterManagementProps> = ({ onFilterChange }) => {
const [filters, setFilters] = useState<FilterState>({
categories: [],
tags: [],
searchQuery: '',
})
const initFilters = usePluginPageContext(v => v.filters) as FilterState
const [filters, setFilters] = useState<FilterState>(initFilters)
const updateFilters = (newFilters: Partial<FilterState>) => {
const updatedFilters = { ...filters, ...newFilters }

View File

@ -1,37 +0,0 @@
import {
useCallback,
useEffect,
useState,
} from 'react'
import { useRequest } from 'ahooks'
import type { PluginTask } from '../types'
import { fetchPluginTasks } from '@/service/plugins'
export const usePluginTasks = () => {
const [pluginTasks, setPluginTasks] = useState<PluginTask[]>([])
const handleUpdatePluginTasks = async (callback: (tasks: PluginTask[]) => void) => {
const { tasks } = await fetchPluginTasks()
setPluginTasks(tasks)
callback(tasks)
}
const { run, cancel } = useRequest(handleUpdatePluginTasks, {
manual: true,
pollingInterval: 3000,
pollingErrorRetryCount: 2,
})
const checkHasPluginTasks = useCallback((tasks: PluginTask[]) => {
if (!tasks.length)
cancel()
}, [cancel])
useEffect(() => {
run(checkHasPluginTasks)
}, [run, checkHasPluginTasks])
return {
pluginTasks,
}
}

View File

@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
import {
RiDragDropLine,
RiEqualizer2Line,
RiInstallFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
@ -17,8 +16,8 @@ import InstallPluginDropdown from './install-plugin-dropdown'
import { useUploader } from './use-uploader'
import usePermission from './use-permission'
import DebugInfo from './debug-info'
import { usePluginTasks } from './hooks'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { usePluginTasksStore } from './store'
import InstallInfo from './install-info'
import Button from '@/app/components/base/button'
import TabSlider from '@/app/components/base/tab-slider'
import Tooltip from '@/app/components/base/tooltip'
@ -100,22 +99,11 @@ const PluginPage = ({
}] = useBoolean()
const [currentFile, setCurrentFile] = useState<File | null>(null)
const containerRef = usePluginPageContext(v => v.containerRef)
const options = usePluginPageContext(v => v.options)
const [activeTab, setActiveTab] = usePluginPageContext(v => [v.activeTab, v.setActiveTab])
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
const [installed, total] = [2, 3] // Replace this with the actual progress
const progressPercentage = (installed / total) * 100
const options = useMemo(() => {
return [
{ value: 'plugins', text: t('common.menus.plugins') },
...(
enable_marketplace
? [{ value: 'discover', text: 'Explore Marketplace' }]
: []
),
]
}, [t, enable_marketplace])
const [activeTab, setActiveTab] = useTabSearchParams({
defaultTab: options[0].value,
})
const uploaderProps = useUploader({
onFileChange: setCurrentFile,
@ -125,10 +113,15 @@ const PluginPage = ({
const { dragging, fileUploader, fileChangeHandle, removeFile } = uploaderProps
const { pluginTasks } = usePluginTasks()
const setPluginTasksWithPolling = usePluginTasksStore(s => s.setPluginTasksWithPolling)
useEffect(() => {
setPluginTasksWithPolling()
}, [setPluginTasksWithPolling])
return (
<div
id='marketplace-container'
ref={containerRef}
className={cn('grow relative flex flex-col overflow-y-auto border-t border-divider-subtle', activeTab === 'plugins'
? 'rounded-t-xl bg-components-panel-bg'
@ -149,22 +142,7 @@ const PluginPage = ({
/>
</div>
<div className='flex flex-shrink-0 items-center gap-1'>
<div className='relative'>
<Button
className='relative overflow-hidden border !border-[rgba(178,202,255,1)] !bg-[rgba(255,255,255,0.95)] cursor-default'
>
<div
className='absolute left-0 top-0 h-full bg-state-accent-active'
style={{ width: `${progressPercentage}%` }}
></div>
<div className='relative z-10 flex items-center'>
<RiInstallFill className='w-4 h-4 text-text-accent' />
<div className='flex px-0.5 justify-center items-center gap-1'>
<span className='text-text-accent system-sm-medium'>{activeTab === 'plugins' ? `Installing ${installed}/${total} plugins` : `${installed}/${total}`}</span>
</div>
</div>
</Button>
</div>
<InstallInfo />
{canManagement && (
<InstallPluginDropdown
onSwitchToMarketplaceTab={() => setActiveTab('discover')}

View File

@ -0,0 +1,86 @@
import {
useState,
} from 'react'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInstallLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import Button from '@/app/components/base/button'
// import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { useMemo } from 'react'
import cn from '@/utils/classnames'
const InstallInfo = () => {
const [open, setOpen] = useState(false)
const status = 'error'
const statusError = useMemo(() => status === 'error', [status])
return (
<div className='flex items-center'>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 79,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Tooltip popupContent='Installing 1/3 plugins...'>
<div
className={cn(
'relative flex items-center justify-center w-8 h-8 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
statusError && 'border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
)}
>
<RiInstallLine
className={cn(
'w-4 h-4 text-components-button-secondary-text',
statusError && 'text-components-button-destructive-secondary-text',
)}
/>
<div className='absolute -right-1 -top-1'>
{/* <ProgressCircle
percentage={33}
circleFillColor='fill-components-progress-brand-bg'
sectorFillColor='fill-components-progress-error-bg'
circleStrokeColor='stroke-components-progress-error-bg'
/> */}
<RiCheckboxCircleFill className='w-3.5 h-3.5 text-text-success' />
</div>
</div>
</Tooltip>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='p-1 pb-2 w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
<div className='flex items-center px-2 pt-1 h-7 system-sm-semibold-uppercase'>3 plugins failed to install</div>
<div className='flex items-center p-1 pl-2 h-8 rounded-lg hover:bg-state-base-hover'>
<div className='relative flex items-center justify-center mr-2 w-6 h-6 rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
<RiErrorWarningFill className='absolute -right-0.5 -bottom-0.5 w-3 h-3 text-text-destructive' />
</div>
<div className='grow system-md-regular text-text-secondary truncate'>
DuckDuckGo Search
</div>
<Button
size='small'
variant='ghost-accent'
>
Clear
</Button>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default InstallInfo

View File

@ -15,6 +15,7 @@ import {
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
type Props = {
onSwitchToMarketplaceTab: () => void
@ -27,6 +28,7 @@ const InstallPluginDropdown = ({
const [selectedAction, setSelectedAction] = useState<string | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
@ -79,7 +81,7 @@ const InstallPluginDropdown = ({
onChange={handleFileChange}
accept='.difypkg'
/>
<div className='p-1 w-full'>
<div className='w-full'>
{[
...(
(enable_marketplace || true)
@ -91,7 +93,7 @@ const InstallPluginDropdown = ({
].map(({ icon: Icon, text, action }) => (
<div
key={action}
className='flex items-center w-full px-2 py-1.5 gap-1 rounded-lg hover:bg-state-base-hover cursor-pointer'
className='flex items-center w-full px-2 py-1.5 gap-1 rounded-lg hover:bg-state-base-hover !cursor-pointer'
onClick={() => {
if (action === 'local') {
fileInputRef.current?.click()
@ -114,12 +116,15 @@ const InstallPluginDropdown = ({
</div>
</PortalToFollowElemContent>
</div>
{selectedAction === 'github' && <InstallFromGitHub onClose={() => setSelectedAction(null)} />}
{selectedAction === 'github' && <InstallFromGitHub
onSuccess={() => { invalidateInstalledPluginList() }}
onClose={() => setSelectedAction(null)}
/>}
{selectedAction === 'local' && selectedFile
&& (<InstallFromLocalPackage
file={selectedFile}
onClose={() => setSelectedAction(null)}
onSuccess={() => { }}
onSuccess={() => {}}
/>
)
}

View File

@ -1,9 +1,9 @@
import type { FC } from 'react'
import PluginItem from '../../plugin-item'
import type { InstalledPlugin } from '../../types'
import type { PluginDetail } from '../../types'
type IPluginListProps = {
pluginList: InstalledPlugin[]
pluginList: PluginDetail[]
}
const PluginList: FC<IPluginListProps> = ({ pluginList }) => {

View File

@ -4,12 +4,13 @@ import React from 'react'
import { useTranslation } from 'react-i18next'
import KeyValueItem from '../base/key-value-item'
import Modal from '../../base/modal'
import { convertRepoToUrl } from '../install-plugin/utils'
const i18nPrefix = 'plugin.pluginInfoModal'
type Props = {
repository: string
release: string
packageName: string
repository?: string
release?: string
packageName?: string
onHide: () => void
}
@ -30,9 +31,9 @@ const PlugInfo: FC<Props> = ({
closable
>
<div className='mt-5 space-y-3'>
<KeyValueItem label={t(`${i18nPrefix}.repository`)} labelWidthClassName={labelWidthClassName} value={repository} />
<KeyValueItem label={t(`${i18nPrefix}.release`)} labelWidthClassName={labelWidthClassName} value={release} />
<KeyValueItem label={t(`${i18nPrefix}.packageName`)} labelWidthClassName={labelWidthClassName} value={packageName} />
{repository && <KeyValueItem label={t(`${i18nPrefix}.repository`)} labelWidthClassName={labelWidthClassName} value={`${convertRepoToUrl(repository)}`} valueMaxWidthClassName='max-w-[190px]' />}
{release && <KeyValueItem label={t(`${i18nPrefix}.release`)} labelWidthClassName={labelWidthClassName} value={release} />}
{packageName && <KeyValueItem label={t(`${i18nPrefix}.packageName`)} labelWidthClassName={labelWidthClassName} value={packageName} />}
</div>
</Modal>
)

View File

@ -1,36 +1,36 @@
'use client'
import { useMemo, useState } from 'react'
import type { EndpointListItem, InstalledPlugin, PluginDetail } from '../types'
import { useMemo } from 'react'
import type { FilterState } from './filter-management'
import FilterManagement from './filter-management'
import List from './list'
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { toolNotion, toolNotionEndpoints } from '@/app/components/plugins/plugin-detail-panel/mock'
import { usePluginPageContext } from './context'
import { useDebounceFn } from 'ahooks'
import Empty from './empty'
import Loading from '../../base/loading'
const PluginsPanel = () => {
const [filters, setFilters] = usePluginPageContext(v => [v.filters, v.setFilters])
const pluginList = usePluginPageContext(v => v.installedPluginList) as InstalledPlugin[]
const [filters, setFilters] = usePluginPageContext(v => [v.filters, v.setFilters]) as [FilterState, (filter: FilterState) => void]
const { data: pluginList, isLoading: isPluginListLoading } = useInstalledPluginList()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const { run: handleFilterChange } = useDebounceFn((filters: FilterState) => {
setFilters(filters)
}, { wait: 500 })
const filteredList = useMemo(() => {
// todo: filter by tags
const { categories, searchQuery } = filters
const filteredList = pluginList.filter((plugin) => {
const { categories, searchQuery, tags } = filters
const filteredList = pluginList?.plugins.filter((plugin) => {
return (
(categories.length === 0 || categories.includes(plugin.declaration.category))
&& (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag)))
&& (searchQuery === '' || plugin.plugin_id.toLowerCase().includes(searchQuery.toLowerCase()))
)
})
return filteredList
}, [pluginList, filters])
const [currentPluginDetail, setCurrentPluginDetail] = useState<PluginDetail | undefined>(toolNotion as any)
const [currentPluginEndpoints, setCurrentEndpoints] = useState<EndpointListItem[]>(toolNotionEndpoints as any)
return (
<>
<div className='flex flex-col pt-1 pb-3 px-12 justify-center items-start gap-3 self-stretch'>
@ -39,19 +39,16 @@ const PluginsPanel = () => {
onFilterChange={handleFilterChange}
/>
</div>
<div className='flex px-12 items-start content-start gap-2 flex-grow self-stretch flex-wrap'>
<div className='w-full'>
<List pluginList={filteredList} />
{isPluginListLoading ? <Loading type='app' /> : (filteredList?.length ?? 0) > 0 ? (
<div className='flex px-12 items-start content-start gap-2 flex-grow self-stretch flex-wrap'>
<div className='w-full'>
<List pluginList={filteredList || []} />
</div>
</div>
</div>
<PluginDetailPanel
pluginDetail={currentPluginDetail}
endpointList={currentPluginEndpoints}
onHide={() => {
setCurrentPluginDetail(undefined)
setCurrentEndpoints([])
}}
/>
) : (
<Empty />
)}
<PluginDetailPanel onDelete={() => invalidateInstalledPluginList()}/>
</>
)
}

View File

@ -0,0 +1,40 @@
import { create } from 'zustand'
import type { PluginTask } from '../types'
import { fetchPluginTasks } from '@/service/plugins'
type PluginTasksStore = {
pluginTasks: PluginTask[]
setPluginTasks: (tasks: PluginTask[]) => void
setPluginTasksWithPolling: () => void
}
let pluginTasksTimer: NodeJS.Timeout | null = null
export const usePluginTasksStore = create<PluginTasksStore>(set => ({
pluginTasks: [],
setPluginTasks: (tasks: PluginTask[]) => set({ pluginTasks: tasks }),
setPluginTasksWithPolling: async () => {
if (pluginTasksTimer) {
clearTimeout(pluginTasksTimer)
pluginTasksTimer = null
}
const handleUpdatePluginTasks = async () => {
const { tasks } = await fetchPluginTasks()
set({ pluginTasks: tasks })
if (tasks.length && !tasks.every(task => task.status === 'success')) {
pluginTasksTimer = setTimeout(() => {
handleUpdatePluginTasks()
}, 5000)
}
else {
if (pluginTasksTimer) {
clearTimeout(pluginTasksTimer)
pluginTasksTimer = null
}
}
}
handleUpdatePluginTasks()
},
}))

View File

@ -2,7 +2,7 @@
import React from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowRightUpLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { RiArrowRightUpLine } from '@remixicon/react'
import Badge from '../base/badge'
import type { Plugin } from './types'
import Description from './card/base/description'
@ -12,7 +12,9 @@ import DownloadCount from './card/base/download-count'
import Button from '@/app/components/base/button'
import { useGetLanguage } from '@/context/i18n'
import { MARKETPLACE_URL_PREFIX } from '@/config'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import cn from '@/utils/classnames'
import { useBoolean } from 'ahooks'
type Props = {
className?: string
@ -24,6 +26,10 @@ const ProviderCard: FC<Props> = ({
payload,
}) => {
const { t } = useTranslation()
const [isShowInstallFromMarketplace, {
setTrue: showInstallFromMarketplace,
setFalse: hideInstallFromMarketplace,
}] = useBoolean(false)
const language = useGetLanguage()
const { org, label } = payload
@ -35,7 +41,7 @@ const ProviderCard: FC<Props> = ({
<div className="ml-3 w-0 grow">
<div className="flex items-center h-5">
<Title title={label[language]} />
<RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" />
{/* <RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" /> */}
</div>
<div className='mb-1 flex justify-between items-center h-4'>
<div className='flex items-center'>
@ -58,6 +64,7 @@ const ProviderCard: FC<Props> = ({
<Button
className='flex-grow'
variant='primary'
onClick={showInstallFromMarketplace}
>
{t('plugin.detailPanel.operation.install')}
</Button>
@ -71,6 +78,16 @@ const ProviderCard: FC<Props> = ({
</a>
</Button>
</div>
{
isShowInstallFromMarketplace && (
<InstallFromMarketplace
manifest={payload as any}
uniqueIdentifier={payload.latest_package_identifier}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)
}
</div>
)
}

View File

@ -54,6 +54,7 @@ export type EndpointListItem = {
// Plugin manifest
export type PluginDeclaration = {
plugin_unique_identifier: string
version: string
author: string
icon: string
@ -68,9 +69,11 @@ export type PluginDeclaration = {
endpoint: PluginEndpointDeclaration
tool: PluginToolDeclaration
model: any // TODO
tags: string[]
}
export type PluginManifestInMarket = {
plugin_unique_identifier: string
name: string
org: string
icon: string
@ -80,6 +83,7 @@ export type PluginManifestInMarket = {
brief: Record<Locale, string>
introduction: string
verified: boolean
install_count: number
}
export type PluginDetail = {
@ -95,8 +99,9 @@ export type PluginDetail = {
endpoints_setups: number
endpoints_active: number
version: string
latest_version: string
source: PluginSource
meta?: any
meta?: MetaData
}
export type Plugin = {
@ -106,6 +111,7 @@ export type Plugin = {
plugin_id: string
version: string
latest_version: string
latest_package_identifier: string
icon: string
verified: boolean
label: Record<Locale, string>
@ -132,11 +138,45 @@ export type Permissions = {
debug_permission: PermissionType
}
export type UpdateFromMarketPlacePayload = {
originalPackageInfo: {
id: string
payload: PluginDeclaration
},
targetPackageInfo: {
id: string
version: string
}
}
export type UpdateFromGitHubPayload = {
originalPackageInfo: {
id: string
repo: string
version: string
package: string
releases: GitHubRepoReleaseResponse[]
}
}
export type UpdatePluginPayload = {
type: PluginSource
marketPlace?: UpdateFromMarketPlacePayload
github?: UpdateFromGitHubPayload
}
export type UpdatePluginModalType = UpdatePluginPayload & {
onCancel: () => void
onSave: () => void
}
export enum InstallStepFromGitHub {
setUrl = 'url',
setVersion = 'version',
setPackage = 'package',
selectPackage = 'selecting',
readyToInstall = 'readyToInstall',
uploadFailed = 'uploadFailed',
installed = 'installed',
installFailed = 'failed',
}
export type InstallState = {
@ -163,7 +203,7 @@ export type EndpointOperationResponse = {
result: 'success' | 'error'
}
export type EndpointsRequest = {
limit: number
page_size: number
page: number
plugin_id: string
}
@ -206,6 +246,11 @@ export type InstallPackageResponse = {
task_id: string
}
export type uploadGitHubResponse = {
unique_identifier: string
manifest: PluginDeclaration
}
export type DebugInfo = {
key: string
host: string
@ -249,20 +294,8 @@ export type MetaData = {
package: string
}
export type InstalledPlugin = {
plugin_id: string
installation_id: string
declaration: PluginDeclaration
source: PluginSource
tenant_id: string
version: string
latest_version: string
endpoints_active: number
meta: MetaData
}
export type InstalledPluginListResponse = {
plugins: InstalledPlugin[]
plugins: PluginDetail[]
}
export type UninstallPluginResponse = {

View File

@ -0,0 +1,26 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { UpdateFromGitHubPayload } from '../types'
import InstallFromGitHub from '../install-plugin/install-from-github'
type Props = {
payload: UpdateFromGitHubPayload
onSave: () => void
onCancel: () => void
}
const FromGitHub: FC<Props> = ({
payload,
onSave,
onCancel,
}) => {
return (
<InstallFromGitHub
updatePayload={payload}
onClose={onCancel}
onSuccess={onSave}
/>
)
}
export default React.memo(FromGitHub)

View File

@ -0,0 +1,153 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { RiInformation2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Card from '@/app/components/plugins/card'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import type { UpdateFromMarketPlacePayload } from '../types'
import { pluginManifestToCardPluginProps } from '@/app/components/plugins/install-plugin/utils'
import useGetIcon from '../install-plugin/base/use-get-icon'
import { updateFromMarketPlace } from '@/service/plugins'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import { usePluginTasksStore } from '@/app/components/plugins/plugin-page/store'
const i18nPrefix = 'plugin.upgrade'
type Props = {
payload: UpdateFromMarketPlacePayload
onSave: () => void
onCancel: () => void
}
enum UploadStep {
notStarted = 'notStarted',
upgrading = 'upgrading',
installed = 'installed',
}
const UpdatePluginModal: FC<Props> = ({
payload,
onSave,
onCancel,
}) => {
const {
originalPackageInfo,
targetPackageInfo,
} = payload
const { t } = useTranslation()
const { getIconUrl } = useGetIcon()
const [icon, setIcon] = useState<string>(originalPackageInfo.payload.icon)
useEffect(() => {
(async () => {
const icon = await getIconUrl(originalPackageInfo.payload.icon)
setIcon(icon)
})()
}, [originalPackageInfo, getIconUrl])
const {
check,
stop,
} = checkTaskStatus()
const handleCancel = () => {
stop()
onCancel()
}
const [uploadStep, setUploadStep] = useState<UploadStep>(UploadStep.notStarted)
const setPluginTasksWithPolling = usePluginTasksStore(s => s.setPluginTasksWithPolling)
const configBtnText = useMemo(() => {
return ({
[UploadStep.notStarted]: t(`${i18nPrefix}.upgrade`),
[UploadStep.upgrading]: t(`${i18nPrefix}.upgrading`),
[UploadStep.installed]: t(`${i18nPrefix}.close`),
})[uploadStep]
}, [t, uploadStep])
const handleConfirm = useCallback(async () => {
if (uploadStep === UploadStep.notStarted) {
setUploadStep(UploadStep.upgrading)
const {
all_installed: isInstalled,
task_id: taskId,
} = await updateFromMarketPlace({
original_plugin_unique_identifier: originalPackageInfo.id,
new_plugin_unique_identifier: targetPackageInfo.id,
})
if (isInstalled) {
onSave()
return
}
setPluginTasksWithPolling()
await check({
taskId,
pluginUniqueIdentifier: targetPackageInfo.id,
})
onSave()
}
if (uploadStep === UploadStep.installed) {
onSave()
onCancel()
}
}, [onCancel, onSave, uploadStep, check, originalPackageInfo.id, setPluginTasksWithPolling, targetPackageInfo.id])
const usedInAppInfo = useMemo(() => {
return (
<div className='flex px-0.5 justify-center items-center gap-0.5'>
<div className='text-text-warning system-xs-medium'>{t(`${i18nPrefix}.usedInApps`, { num: 3 })}</div>
{/* show the used apps */}
<RiInformation2Line className='w-4 h-4 text-text-tertiary' />
</div>
)
}, [t])
return (
<Modal
isShow={true}
onClose={onCancel}
className='min-w-[560px]'
closable
title={t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`)}
>
<div className='mt-3 mb-2 text-text-secondary system-md-regular'>
{t(`${i18nPrefix}.description`)}
</div>
<div className='flex p-2 items-start content-start gap-1 self-stretch flex-wrap rounded-2xl bg-background-section-burn'>
<Card
installed={uploadStep === UploadStep.installed}
payload={pluginManifestToCardPluginProps({
...originalPackageInfo.payload,
icon: icon!,
})}
className='w-full'
titleLeft={
<>
<Badge className='mx-1' size="s" state={BadgeState.Warning}>
{`${originalPackageInfo.payload.version} -> ${targetPackageInfo.version}`}
</Badge>
{false && usedInAppInfo}
</>
}
/>
</div>
<div className='flex pt-5 justify-end items-center gap-2 self-stretch'>
{uploadStep === UploadStep.notStarted && (
<Button
onClick={handleCancel}
>
{t('common.operation.cancel')}
</Button>
)}
<Button
variant='primary'
loading={uploadStep === UploadStep.upgrading}
onClick={handleConfirm}
disabled={uploadStep === UploadStep.upgrading}
>
{configBtnText}
</Button>
</div>
</Modal>
)
}
export default React.memo(UpdatePluginModal)

View File

@ -1,97 +1,33 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { RiInformation2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Card from '@/app/components/plugins/card'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import { toolNotion } from '@/app/components/plugins/card/card-mock'
import React from 'react'
import type { UpdatePluginModalType } from '../types'
import { PluginSource } from '../types'
import UpdateFromGitHub from './from-github'
import UpdateFromMarketplace from './from-market-place'
const i18nPrefix = 'plugin.upgrade'
type Props = {
onHide: () => void
}
enum UploadStep {
notStarted = 'notStarted',
upgrading = 'upgrading',
installed = 'installed',
}
const UpdatePluginModal: FC<Props> = ({
onHide,
const UpdatePlugin: FC<UpdatePluginModalType> = ({
type,
marketPlace,
github,
onCancel,
onSave,
}) => {
const { t } = useTranslation()
const [uploadStep, setUploadStep] = useState<UploadStep>(UploadStep.notStarted)
const configBtnText = useMemo(() => {
return ({
[UploadStep.notStarted]: t(`${i18nPrefix}.upgrade`),
[UploadStep.upgrading]: t(`${i18nPrefix}.upgrading`),
[UploadStep.installed]: t(`${i18nPrefix}.close`),
})[uploadStep]
}, [uploadStep])
const handleConfirm = useCallback(() => {
if (uploadStep === UploadStep.notStarted) {
setUploadStep(UploadStep.upgrading)
setTimeout(() => {
setUploadStep(UploadStep.installed)
}, 1500)
return
}
if (uploadStep === UploadStep.installed)
onHide()
}, [uploadStep])
if (type === PluginSource.github) {
return (
<UpdateFromGitHub
payload={github!}
onSave={onSave}
onCancel={onCancel}
/>
)
}
return (
<Modal
isShow={true}
onClose={onHide}
className='min-w-[560px]'
closable
title={t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`)}
>
<div className='mt-3 mb-2 text-text-secondary system-md-regular'>
{t(`${i18nPrefix}.description`)}
</div>
<div className='flex p-2 items-start content-start gap-1 self-stretch flex-wrap rounded-2xl bg-background-section-burn'>
<Card
installed={uploadStep === UploadStep.installed}
payload={toolNotion as any}
className='w-full'
titleLeft={
<>
<Badge className='mx-1' size="s" state={BadgeState.Warning}>
{'1.2.0 -> 1.3.2'}
</Badge>
<div className='flex px-0.5 justify-center items-center gap-0.5'>
<div className='text-text-warning system-xs-medium'>{t(`${i18nPrefix}.usedInApps`, { num: 3 })}</div>
{/* show the used apps */}
<RiInformation2Line className='w-4 h-4 text-text-tertiary' />
</div>
</>
}
/>
</div>
<div className='flex pt-5 justify-end items-center gap-2 self-stretch'>
{uploadStep === UploadStep.notStarted && (
<Button
onClick={onHide}
>
{t('common.operation.cancel')}
</Button>
)}
<Button
variant='primary'
loading={uploadStep === UploadStep.upgrading}
onClick={handleConfirm}
disabled={uploadStep === UploadStep.upgrading}
>
{configBtnText}
</Button>
</div>
</Modal>
<UpdateFromMarketplace
payload={marketPlace!}
onSave={onSave}
onCancel={onCancel}
/>
)
}
export default React.memo(UpdatePluginModal)
export default React.memo(UpdatePlugin)

View File

@ -0,0 +1,361 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounce, useGetState } from 'ahooks'
import produce from 'immer'
import { LinkExternal02, Settings01 } from '../../base/icons/src/vender/line/general'
import type { Credential, CustomCollectionBackend, CustomParamSchema, Emoji } from '../types'
import { AuthHeaderPrefix, AuthType } from '../types'
import GetSchema from './get-schema'
import ConfigCredentials from './config-credentials'
import TestApi from './test-api'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import EmojiPicker from '@/app/components/base/emoji-picker'
import AppIcon from '@/app/components/base/app-icon'
import { parseParamsSchema } from '@/service/tools'
import LabelSelector from '@/app/components/tools/labels/selector'
import Toast from '@/app/components/base/toast'
import Modal from '../../base/modal'
import Button from '@/app/components/base/button'
const fieldNameClassNames = 'py-2 leading-5 text-sm font-medium text-gray-900'
type Props = {
positionLeft?: boolean
payload: any
onHide: () => void
onAdd?: (payload: CustomCollectionBackend) => void
onRemove?: () => void
onEdit?: (payload: CustomCollectionBackend) => void
}
// Add and Edit
const EditCustomCollectionModal: FC<Props> = ({
payload,
onHide,
onAdd,
onEdit,
onRemove,
}) => {
const { t } = useTranslation()
const isAdd = !payload
const isEdit = !!payload
const [editFirst, setEditFirst] = useState(!isAdd)
const [paramsSchemas, setParamsSchemas] = useState<CustomParamSchema[]>(payload?.tools || [])
const [customCollection, setCustomCollection, getCustomCollection] = useGetState<CustomCollectionBackend>(isAdd
? {
provider: '',
credentials: {
auth_type: AuthType.none,
api_key_header: 'Authorization',
api_key_header_prefix: AuthHeaderPrefix.basic,
},
icon: {
content: '🕵️',
background: '#FEF7C3',
},
schema_type: '',
schema: '',
}
: payload)
const originalProvider = isEdit ? payload.provider : ''
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const emoji = customCollection.icon
const setEmoji = (emoji: Emoji) => {
const newCollection = produce(customCollection, (draft) => {
draft.icon = emoji
})
setCustomCollection(newCollection)
}
const schema = customCollection.schema
const debouncedSchema = useDebounce(schema, { wait: 500 })
const setSchema = (schema: any) => {
const newCollection = produce(customCollection, (draft) => {
draft.schema = schema
})
setCustomCollection(newCollection)
}
useEffect(() => {
if (!debouncedSchema)
return
if (isEdit && editFirst) {
setEditFirst(false)
return
}
(async () => {
try {
const { parameters_schema, schema_type } = await parseParamsSchema(debouncedSchema)
const customCollection = getCustomCollection()
const newCollection = produce(customCollection, (draft) => {
draft.schema_type = schema_type
})
setCustomCollection(newCollection)
setParamsSchemas(parameters_schema)
}
catch (e) {
const customCollection = getCustomCollection()
const newCollection = produce(customCollection, (draft) => {
draft.schema_type = ''
})
setCustomCollection(newCollection)
setParamsSchemas([])
}
})()
}, [debouncedSchema])
const [credentialsModalShow, setCredentialsModalShow] = useState(false)
const credential = customCollection.credentials
const setCredential = (credential: Credential) => {
const newCollection = produce(customCollection, (draft) => {
draft.credentials = credential
})
setCustomCollection(newCollection)
}
const [currTool, setCurrTool] = useState<CustomParamSchema | null>(null)
const [isShowTestApi, setIsShowTestApi] = useState(false)
const [labels, setLabels] = useState<string[]>(payload?.labels || [])
const handleLabelSelect = (value: string[]) => {
setLabels(value)
}
const handleSave = () => {
// const postData = clone(customCollection)
const postData = produce(customCollection, (draft) => {
delete draft.tools
if (draft.credentials.auth_type === AuthType.none) {
delete draft.credentials.api_key_header
delete draft.credentials.api_key_header_prefix
delete draft.credentials.api_key_value
}
draft.labels = labels
})
let errorMessage = ''
if (!postData.provider)
errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.name') })
if (!postData.schema)
errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.schema') })
if (errorMessage) {
Toast.notify({
type: 'error',
message: errorMessage,
})
return
}
if (isAdd) {
onAdd?.(postData)
return
}
onEdit?.({
...postData,
original_provider: originalProvider,
})
}
const getPath = (url: string) => {
if (!url)
return ''
try {
const path = decodeURI(new URL(url).pathname)
return path || ''
}
catch (e) {
return url
}
}
return (
<>
<Modal
isShow
onClose={onHide}
closable
className='!p-0 !max-w-[630px] !h-[calc(100vh-16px)]'
>
<div className='flex flex-col h-full'>
<div className='ml-6 mt-6 text-base font-semibold text-gray-900'>
{t('tools.createTool.title')}
</div>
<div className='grow h-0 overflow-y-auto px-6 py-3 space-y-4'>
<div>
<div className={fieldNameClassNames}>{t('tools.createTool.name')} <span className='ml-1 text-red-500'>*</span></div>
<div className='flex items-center justify-between gap-3'>
<AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.content} background={emoji.background} />
<Input
className='h-10 grow' placeholder={t('tools.createTool.toolNamePlaceHolder')!}
value={customCollection.provider}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.provider = e.target.value
})
setCustomCollection(newCollection)
}}
/>
</div>
</div>
{/* Schema */}
<div className='select-none'>
<div className='flex justify-between items-center'>
<div className='flex items-center'>
<div className={fieldNameClassNames}>{t('tools.createTool.schema')}<span className='ml-1 text-red-500'>*</span></div>
<div className='mx-2 w-px h-3 bg-black/5'></div>
<a
href="https://swagger.io/specification/"
target='_blank' rel='noopener noreferrer'
className='flex items-center h-[18px] space-x-1 text-[#155EEF]'
>
<div className='text-xs font-normal'>{t('tools.createTool.viewSchemaSpec')}</div>
<LinkExternal02 className='w-3 h-3' />
</a>
</div>
<GetSchema onChange={setSchema} />
</div>
<Textarea
className='h-[240px] resize-none'
value={schema}
onChange={e => setSchema(e.target.value)}
placeholder={t('tools.createTool.schemaPlaceHolder')!}
/>
</div>
{/* Available Tools */}
<div>
<div className={fieldNameClassNames}>{t('tools.createTool.availableTools.title')}</div>
<div className='rounded-lg border border-gray-200 w-full overflow-x-auto'>
<table className='w-full leading-[18px] text-xs text-gray-700 font-normal'>
<thead className='text-gray-500 uppercase'>
<tr className={cn(paramsSchemas.length > 0 && 'border-b', 'border-gray-200')}>
<th className="p-2 pl-3 font-medium">{t('tools.createTool.availableTools.name')}</th>
<th className="p-2 pl-3 font-medium w-[236px]">{t('tools.createTool.availableTools.description')}</th>
<th className="p-2 pl-3 font-medium">{t('tools.createTool.availableTools.method')}</th>
<th className="p-2 pl-3 font-medium">{t('tools.createTool.availableTools.path')}</th>
<th className="p-2 pl-3 font-medium w-[54px]">{t('tools.createTool.availableTools.action')}</th>
</tr>
</thead>
<tbody>
{paramsSchemas.map((item, index) => (
<tr key={index} className='border-b last:border-0 border-gray-200'>
<td className="p-2 pl-3">{item.operation_id}</td>
<td className="p-2 pl-3 text-gray-500 w-[236px]">{item.summary}</td>
<td className="p-2 pl-3">{item.method}</td>
<td className="p-2 pl-3">{getPath(item.server_url)}</td>
<td className="p-2 pl-3 w-[62px]">
<Button
size='small'
onClick={() => {
setCurrTool(item)
setIsShowTestApi(true)
}}
>
{t('tools.createTool.availableTools.test')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Authorization method */}
<div>
<div className={fieldNameClassNames}>{t('tools.createTool.authMethod.title')}</div>
<div className='flex items-center h-9 justify-between px-2.5 bg-gray-100 rounded-lg cursor-pointer' onClick={() => setCredentialsModalShow(true)}>
<div className='text-sm font-normal text-gray-900'>{t(`tools.createTool.authMethod.types.${credential.auth_type}`)}</div>
<Settings01 className='w-4 h-4 text-gray-700 opacity-60' />
</div>
</div>
{/* Labels */}
<div>
<div className='py-2 leading-5 text-sm font-medium text-gray-900'>{t('tools.createTool.toolInput.label')}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className={fieldNameClassNames}>{t('tools.createTool.privacyPolicy')}</div>
<Input
value={customCollection.privacy_policy}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.privacy_policy = e.target.value
})
setCustomCollection(newCollection)
}}
className='h-10 grow' placeholder={t('tools.createTool.privacyPolicyPlaceholder') || ''} />
</div>
<div>
<div className={fieldNameClassNames}>{t('tools.createTool.customDisclaimer')}</div>
<Input
value={customCollection.custom_disclaimer}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.custom_disclaimer = e.target.value
})
setCustomCollection(newCollection)
}}
className='h-10 grow' placeholder={t('tools.createTool.customDisclaimerPlaceholder') || ''} />
</div>
</div>
<div className={cn(isEdit ? 'justify-between' : 'justify-end', 'mt-2 shrink-0 flex py-4 px-6 rounded-b-[10px] bg-gray-50 border-t border-black/5')} >
{
isEdit && (
<Button onClick={onRemove} className='text-red-500 border-red-50 hover:border-red-500'>{t('common.operation.delete')}</Button>
)
}
<div className='flex space-x-2 '>
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button variant='primary' onClick={handleSave}>{t('common.operation.save')}</Button>
</div>
</div>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
}}
/>}
{credentialsModalShow && (
<ConfigCredentials
positionCenter={isAdd}
credential={credential}
onChange={setCredential}
onHide={() => setCredentialsModalShow(false)}
/>)
}
{isShowTestApi && (
<TestApi
positionCenter={isAdd}
tool={currTool as CustomParamSchema}
customCollection={customCollection}
onHide={() => setIsShowTestApi(false)}
/>
)}
</div>
</Modal>
</>
)
}
export default React.memo(EditCustomCollectionModal)

View File

@ -1,6 +1,4 @@
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
export type Label = {
name: string
icon: string
label: TypeWithI18N
label: string
}

View File

@ -1,10 +1,8 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useDebounceFn, useMount } from 'ahooks'
import { useDebounceFn } from 'ahooks'
import { RiArrowDownSLine } from '@remixicon/react'
import { useStore as useLabelStore } from './store'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
@ -16,9 +14,7 @@ import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financ
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import type { Label } from '@/app/components/tools/labels/constant'
import { fetchLabelList } from '@/service/tools'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import { useTags } from '@/app/components/plugins/hooks'
type LabelFilterProps = {
value: string[]
@ -29,12 +25,9 @@ const LabelFilter: FC<LabelFilterProps> = ({
onChange,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const [open, setOpen] = useState(false)
const labelList = useLabelStore(s => s.labelList)
const setLabelList = useLabelStore(s => s.setLabelList)
const { tags: labelList } = useTags()
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
@ -61,12 +54,6 @@ const LabelFilter: FC<LabelFilterProps> = ({
onChange([...value, label.name])
}
useMount(() => {
fetchLabelList().then((res) => {
setLabelList(res)
})
})
return (
<PortalToFollowElem
open={open}
@ -90,7 +77,7 @@ const LabelFilter: FC<LabelFilterProps> = ({
</div>
<div className='text-[13px] leading-[18px] text-gray-700'>
{!value.length && t('common.tag.placeholder')}
{!!value.length && currentLabel?.label[language]}
{!!value.length && currentLabel?.label}
</div>
{value.length > 1 && (
<div className='text-xs font-medium leading-[18px] text-gray-500'>{`+${value.length - 1}`}</div>
@ -128,7 +115,7 @@ const LabelFilter: FC<LabelFilterProps> = ({
className='flex items-center gap-2 pl-3 py-[6px] pr-2 rounded-lg cursor-pointer hover:bg-gray-100'
onClick={() => selectLabel(label)}
>
<div title={label.label[language]} className='grow text-sm text-gray-700 leading-5 truncate'>{label.label[language]}</div>
<div title={label.label} className='grow text-sm text-gray-700 leading-5 truncate'>{label.label}</div>
{value.includes(label.name) && <Check className='shrink-0 w-4 h-4 text-primary-600' />}
</div>
))}

View File

@ -1,10 +1,8 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useDebounceFn, useMount } from 'ahooks'
import { useDebounceFn } from 'ahooks'
import { RiArrowDownSLine } from '@remixicon/react'
import { useStore as useLabelStore } from './store'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
@ -15,9 +13,7 @@ import Input from '@/app/components/base/input'
import { Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Checkbox from '@/app/components/base/checkbox'
import type { Label } from '@/app/components/tools/labels/constant'
import { fetchLabelList } from '@/service/tools'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import { useTags } from '@/app/components/plugins/hooks'
type LabelSelectorProps = {
value: string[]
@ -28,12 +24,9 @@ const LabelSelector: FC<LabelSelectorProps> = ({
onChange,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const [open, setOpen] = useState(false)
const labelList = useLabelStore(s => s.labelList)
const setLabelList = useLabelStore(s => s.setLabelList)
const { tags: labelList } = useTags()
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
@ -50,8 +43,8 @@ const LabelSelector: FC<LabelSelectorProps> = ({
}, [labelList, searchKeywords])
const selectedLabels = useMemo(() => {
return value.map(v => labelList.find(l => l.name === v)?.label[language]).join(', ')
}, [value, labelList, language])
return value.map(v => labelList.find(l => l.name === v)?.label).join(', ')
}, [value, labelList])
const selectLabel = (label: Label) => {
if (value.includes(label.name))
@ -60,12 +53,6 @@ const LabelSelector: FC<LabelSelectorProps> = ({
onChange([...value, label.name])
}
useMount(() => {
fetchLabelList().then((res) => {
setLabelList(res)
})
})
return (
<PortalToFollowElem
open={open}
@ -114,7 +101,7 @@ const LabelSelector: FC<LabelSelectorProps> = ({
checked={value.includes(label.name)}
onCheck={() => { }}
/>
<div title={label.label[language]} className='grow text-sm text-gray-700 leading-5 truncate'>{label.label[language]}</div>
<div title={label.label} className='grow text-sm text-gray-700 leading-5 truncate'>{label.label}</div>
</div>
))}
{!filteredLabelList.length && (

View File

@ -1,15 +0,0 @@
import { create } from 'zustand'
import type { Label } from './constant'
type State = {
labelList: Label[]
}
type Action = {
setLabelList: (labelList?: Label[]) => void
}
export const useStore = create<State & Action>(set => ({
labelList: [],
setLabelList: labelList => set(() => ({ labelList })),
}))

View File

@ -37,7 +37,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
}
else {
queryMarketplaceCollectionsAndPlugins()
setPlugins([])
setPlugins(undefined)
}
}, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, setPlugins])

View File

@ -1,7 +1,9 @@
import { RiArrowUpDoubleLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useMarketplace } from './hooks'
import List from '@/app/components/plugins/marketplace/list'
import Loading from '@/app/components/base/loading'
import { getLocaleOnClient } from '@/i18n'
type MarketplaceProps = {
searchPluginText: string
@ -13,6 +15,8 @@ const Marketplace = ({
filterPluginTags,
onMarketplaceScroll,
}: MarketplaceProps) => {
const locale = getLocaleOnClient()
const { t } = useTranslation()
const {
isLoading,
marketplaceCollections,
@ -31,19 +35,19 @@ const Marketplace = ({
<div className='flex items-center text-center body-md-regular text-text-tertiary'>
Discover
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
models
{t('plugin.category.models')}
</span>
,
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
tools
{t('plugin.category.tools')}
</span>
,
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
extensions
{t('plugin.category.extensions')}
</span>
and
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
bundles
{t('plugin.category.bundles')}
</span>
in Dify Marketplace
</div>
@ -62,6 +66,7 @@ const Marketplace = ({
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins}
showInstallButton
locale={locale}
/>
)
}

View File

@ -293,21 +293,23 @@ const ProviderDetail = ({
<div className='pt-3'>
{isDetailLoading && <div className='flex h-[200px]'><Loading type='app' /></div>}
{/* Builtin type */}
{!isDetailLoading && (collection.type === CollectionType.builtIn) && needAuth && isAuthed && (
{!isDetailLoading && (collection.type === CollectionType.builtIn) && isAuthed && (
<div className='mb-1 h-6 flex items-center justify-between text-text-secondary system-sm-semibold-uppercase'>
{t('plugin.detailPanel.actionNum', { num: 3 })}
<Button
variant='secondary'
size='small'
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
{needAuth && (
<Button
variant='secondary'
size='small'
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div>
)}
{!isDetailLoading && (collection.type === CollectionType.builtIn) && needAuth && !isAuthed && (

View File

@ -1,4 +1,5 @@
import {
useEffect,
useMemo,
useRef,
useState,
@ -14,9 +15,10 @@ import ViewTypeSelect, { ViewType } from './view-type-select'
import cn from '@/utils/classnames'
import { useGetLanguage } from '@/context/i18n'
import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { extensionDallE, modelGPT4, toolNotion } from '@/app/components/plugins/card/card-mock'
import ActionButton from '../../base/action-button'
import { RiAddLine } from '@remixicon/react'
import { PluginType } from '../../plugins/types'
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
type AllToolsProps = {
className?: string
@ -26,6 +28,8 @@ type AllToolsProps = {
workflowTools: ToolWithProvider[]
onSelect: OnSelectBlock
supportAddCustomTool?: boolean
onAddedCustomTool?: () => void
onShowAddCustomCollectionModal?: () => void
}
const AllTools = ({
className,
@ -35,6 +39,7 @@ const AllTools = ({
workflowTools,
customTools,
supportAddCustomTool,
onShowAddCustomCollectionModal,
}: AllToolsProps) => {
const language = useGetLanguage()
const tabs = useToolTabs()
@ -59,6 +64,21 @@ const AllTools = ({
})
}, [activeTab, buildInTools, customTools, workflowTools, searchText, language])
const {
queryPluginsWithDebounced: fetchPlugins,
plugins: notInstalledPlugins = [],
} = useMarketplacePlugins()
useEffect(() => {
if (searchText) {
fetchPlugins({
query: searchText,
category: PluginType.tool,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchText])
const pluginRef = useRef(null)
const wrapElemRef = useRef<HTMLDivElement>(null)
@ -84,9 +104,15 @@ const AllTools = ({
</div>
<ViewTypeSelect viewType={activeView} onChange={setActiveView} />
{supportAddCustomTool && (
<ActionButton>
<RiAddLine className='w-4 h-4' />
</ActionButton>
<div className='flex items-center'>
<div className='mr-1.5 w-px h-3.5 bg-divider-regular'></div>
<ActionButton
className='bg-components-button-primary-bg hover:bg-components-button-primary-bg text-components-button-primary-text hover:text-components-button-primary-text'
onClick={onShowAddCustomCollectionModal}
>
<RiAddLine className='w-4 h-4' />
</ActionButton>
</div>
)}
</div>
<div
@ -103,7 +129,7 @@ const AllTools = ({
{/* Plugins from marketplace */}
<PluginList
wrapElemRef={wrapElemRef}
list={[toolNotion, extensionDallE, modelGPT4] as any} ref={pluginRef}
list={notInstalledPlugins as any} ref={pluginRef}
searchText={searchText}
/>
</div>

View File

@ -26,6 +26,8 @@ const Item: FC<Props> = ({
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
const { locale } = useContext(I18n)
const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj?.[locale] || obj?.['en-US'] || obj?.en_US || ''
return (
<div className='group/plugin flex rounded-lg py-1 pr-1 pl-3 hover:bg-state-base-hover'>
@ -35,8 +37,8 @@ const Item: FC<Props> = ({
/>
<div className='ml-2 w-0 grow flex'>
<div className='w-0 grow'>
<div className='h-4 leading-4 text-text-primary system-sm-medium truncate '>{payload.label[locale]}</div>
<div className='h-5 leading-5 text-text-tertiary system-xs-regular truncate'>{payload.brief[locale]}</div>
<div className='h-4 leading-4 text-text-primary system-sm-medium truncate '>{getLocalizedText(payload.label)}</div>
<div className='h-5 leading-5 text-text-tertiary system-xs-regular truncate'>{getLocalizedText(payload.brief)}</div>
<div className='flex text-text-tertiary system-xs-regular space-x-1'>
<div>{payload.org}</div>
<div>·</div>

View File

@ -1,5 +1,5 @@
'use client'
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll'
import Item from './item'
@ -30,7 +30,6 @@ const List = ({
wrapElemRef,
nextToStickyELemRef,
})
const stickyClassName = useMemo(() => {
switch (scrollPosition) {
case ScrollPosition.aboveTheWrap:
@ -38,7 +37,7 @@ const List = ({
case ScrollPosition.showing:
return 'bottom-0 pt-3 pb-1'
case ScrollPosition.belowTheWrap:
return 'bottom-0 items-center rounded-b-xl border-t border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg cursor-pointer'
return 'bottom-0 items-center rounded-b-xl border-t border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg rounded-b-lg cursor-pointer'
}
}, [scrollPosition])
@ -46,6 +45,11 @@ const List = ({
handleScroll,
}))
useEffect(() => {
handleScroll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [list])
const handleHeadClick = () => {
if (scrollPosition === ScrollPosition.belowTheWrap) {
nextToStickyELemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
@ -57,7 +61,7 @@ const List = ({
if (hasSearchText) {
return (
<Link
className='sticky bottom-0 z-10 flex h-8 px-4 py-1 system-sm-medium items-center rounded-b-xl border-t border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg text-text-accent-light-mode-only cursor-pointer'
className='sticky bottom-0 z-10 flex h-8 px-4 py-1 system-sm-medium items-center border-t border-[0.5px] border-components-panel-border bg-components-panel-bg-blur rounded-b-lg shadow-lg text-text-accent-light-mode-only cursor-pointer'
href={`${marketplaceUrlPrefix}/plugins`}
target='_blank'
>

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@ -13,13 +13,17 @@ import type {
} from '@floating-ui/react'
import AllTools from '@/app/components/workflow/block-selector/all-tools'
import type { ToolDefaultValue } from './types'
import {
fetchAllBuiltInTools,
fetchAllCustomTools,
fetchAllWorkflowTools,
} from '@/service/tools'
import type { BlockEnum, ToolWithProvider } from '@/app/components/workflow/types'
import type { BlockEnum } from '@/app/components/workflow/types'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal/modal'
import {
createCustomCollection,
} from '@/service/tools'
import type { CustomCollectionBackend } from '@/app/components/tools/types'
import Toast from '@/app/components/base/toast'
import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools, useInvalidateAllCustomTools } from '@/service/use-tools'
type Props = {
disabled: boolean
@ -42,22 +46,15 @@ const ToolPicker: FC<Props> = ({
onSelect,
supportAddCustomTool,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const [buildInTools, setBuildInTools] = useState<ToolWithProvider[]>([])
const [customTools, setCustomTools] = useState<ToolWithProvider[]>([])
const [workflowTools, setWorkflowTools] = useState<ToolWithProvider[]>([])
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const invalidateCustomTools = useInvalidateAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
useEffect(() => {
(async () => {
const buildInTools = await fetchAllBuiltInTools()
const customTools = await fetchAllCustomTools()
const workflowTools = await fetchAllWorkflowTools()
setBuildInTools(buildInTools)
setCustomTools(customTools)
setWorkflowTools(workflowTools)
})()
}, [])
const handleAddedCustomTool = invalidateCustomTools
const handleTriggerClick = () => {
if (disabled) return
@ -68,6 +65,32 @@ const ToolPicker: FC<Props> = ({
onSelect(tool!)
}
const [isShowEditCollectionToolModal, {
setFalse: hideEditCustomCollectionModal,
setTrue: showEditCustomCollectionModal,
}] = useBoolean(false)
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
hideEditCustomCollectionModal()
handleAddedCustomTool()
}
if (isShowEditCollectionToolModal) {
return (
<EditCustomToolModal
positionLeft
payload={null}
onHide={hideEditCustomCollectionModal}
onAdd={doCreateCustomToolCollection}
/>
)
}
return (
<PortalToFollowElem
placement={placement}
@ -76,30 +99,34 @@ const ToolPicker: FC<Props> = ({
onOpenChange={onShowChange}
>
<PortalToFollowElemTrigger
asChild
onClick={handleTriggerClick}
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className="relative w-[320px] min-h-20 bg-white">
<SearchBox
search={searchText}
onSearchChange={setSearchText}
tags={[]}
onTagsChange={() => {}}
size='small'
placeholder='Search tools...'
/>
{ }
<div className="relative w-[320px] min-h-20 rounded-xl bg-components-panel-bg-blur border-[0.5px] border-components-panel-border shadow-lg">
<div className='p-2 pb-1'>
<SearchBox
search={searchText}
onSearchChange={setSearchText}
tags={[]}
onTagsChange={() => { }}
size='small'
placeholder={t('plugin.searchTools')!}
/>
</div>
<AllTools
className='mt-1'
searchText={searchText}
onSelect={handleSelect}
buildInTools={buildInTools}
customTools={customTools}
workflowTools={workflowTools}
buildInTools={buildInTools || []}
customTools={customTools || []}
workflowTools={workflowTools || []}
supportAddCustomTool={supportAddCustomTool}
onAddedCustomTool={handleAddedCustomTool}
onShowAddCustomCollectionModal={showEditCustomCollectionModal}
/>
</div>
</PortalToFollowElemContent>

View File

@ -10,14 +10,12 @@ import { useGetLanguage } from '@/context/i18n'
import BlockIcon from '../../block-icon'
type Props = {
className?: string
provider: ToolWithProvider
payload: Tool
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
}
const ToolItem: FC<Props> = ({
className,
provider,
payload,
onSelect,
@ -46,6 +44,12 @@ const ToolItem: FC<Props> = ({
key={payload.name}
className='rounded-lg pl-[21px] hover:bg-state-base-hover cursor-pointer'
onClick={() => {
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.Tool, {
provider_id: provider.id,
provider_type: provider.type,
@ -53,6 +57,7 @@ const ToolItem: FC<Props> = ({
tool_name: payload.name,
tool_label: payload.label[language],
title: payload.label[language],
params,
})
}}
>

View File

@ -66,6 +66,12 @@ const Tool: FC<Props> = ({
toggleFold()
return
}
// TODO: get workflow and custom tool params
// if (payload.parameters) {
// payload.parameters.forEach((item) => {
// params[item.name] = ''
// })
// }
onSelect(BlockEnum.Tool, {
provider_id: payload.id,
provider_type: payload.type,
@ -73,6 +79,7 @@ const Tool: FC<Props> = ({
tool_name: payload.name,
tool_label: payload.label[language],
title: payload.label[language],
params: {},
})
}}
>

View File

@ -25,4 +25,5 @@ export type ToolDefaultValue = {
tool_name: string
tool_label: string
title: string
params: Record<string, any>
}

View File

@ -481,7 +481,6 @@ export const useWorkflowInit = () => {
return acc
}, {} as Record<string, string>),
environmentVariables: res.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [],
// #TODO chatVar sync#
conversationVariables: res.conversation_variables || [],
})
setSyncWorkflowDraftHash(res.hash)

View File

@ -72,7 +72,7 @@ export type CommonNodeType<T = {}> = {
height?: number
} & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>>
export interface CommonEdgeType {
export type CommonEdgeType = {
_hovering?: boolean
_connectedNodeIsHovering?: boolean
_connectedNodeIsSelected?: boolean
@ -86,14 +86,14 @@ export interface CommonEdgeType {
export type Node<T = {}> = ReactFlowNode<CommonNodeType<T>>
export type SelectedNode = Pick<Node, 'id' | 'data'>
export interface NodeProps<T = unknown> { id: string; data: CommonNodeType<T> }
export interface NodePanelProps<T> {
export type NodeProps<T = unknown> = { id: string; data: CommonNodeType<T> }
export type NodePanelProps<T> = {
id: string
data: CommonNodeType<T>
}
export type Edge = ReactFlowEdge<CommonEdgeType>
export interface WorkflowDataUpdater {
export type WorkflowDataUpdater = {
nodes: Node[]
edges: Edge[]
viewport: Viewport
@ -101,7 +101,7 @@ export interface WorkflowDataUpdater {
export type ValueSelector = string[] // [nodeId, key | obj key path]
export interface Variable {
export type Variable = {
variable: string
label?: string | {
nodeType: BlockEnum
@ -116,14 +116,14 @@ export interface Variable {
isParagraph?: boolean
}
export interface EnvironmentVariable {
export type EnvironmentVariable = {
id: string
name: string
value: any
value_type: 'string' | 'number' | 'secret'
}
export interface ConversationVariable {
export type ConversationVariable = {
id: string
name: string
value_type: ChatVarType
@ -131,13 +131,13 @@ export interface ConversationVariable {
description: string
}
export interface GlobalVariable {
export type GlobalVariable = {
name: string
value_type: 'string' | 'number'
description: string
}
export interface VariableWithValue {
export type VariableWithValue = {
key: string
value: string
}
@ -173,7 +173,7 @@ export type InputVar = {
value_selector?: ValueSelector
} & Partial<UploadFileSetting>
export interface ModelConfig {
export type ModelConfig = {
provider: string
name: string
mode: string
@ -191,7 +191,7 @@ export enum EditionType {
jinja2 = 'jinja2',
}
export interface PromptItem {
export type PromptItem = {
id?: string
role?: PromptRole
text: string
@ -204,12 +204,12 @@ export enum MemoryRole {
assistant = 'assistant',
}
export interface RolePrefix {
export type RolePrefix = {
user: string
assistant: string
}
export interface Memory {
export type Memory = {
role_prefix?: RolePrefix
window: {
enabled: boolean
@ -233,7 +233,7 @@ export enum VarType {
any = 'any',
}
export interface Var {
export type Var = {
variable: string
type: VarType
children?: Var[] // if type is obj, has the children struct
@ -244,21 +244,21 @@ export interface Var {
des?: string
}
export interface NodeOutPutVar {
export type NodeOutPutVar = {
nodeId: string
title: string
vars: Var[]
isStartNode?: boolean
}
export interface Block {
export type Block = {
classification?: string
type: BlockEnum
title: string
description?: string
}
export interface NodeDefault<T> {
export type NodeDefault<T> = {
defaultValue: Partial<T>
getAvailablePrevNodes: (isChatMode: boolean) => BlockEnum[]
getAvailableNextNodes: (isChatMode: boolean) => BlockEnum[]
@ -298,19 +298,19 @@ export type OnNodeAdd = (
}
) => void
export interface CheckValidRes {
export type CheckValidRes = {
isValid: boolean
errorMessage?: string
}
export interface RunFile {
export type RunFile = {
type: string
transfer_method: TransferMethod[]
url?: string
upload_file_id?: string
}
export interface WorkflowRunningData {
export type WorkflowRunningData = {
task_id?: string
message_id?: string
conversation_id?: string
@ -335,7 +335,7 @@ export interface WorkflowRunningData {
tracing?: NodeTracing[]
}
export interface HistoryWorkflowData {
export type HistoryWorkflowData = {
id: string
sequence_number: number
status: string
@ -347,7 +347,7 @@ export enum ChangeType {
remove = 'remove',
}
export interface MoreInfo {
export type MoreInfo = {
type: ChangeType
payload?: {
beforeKey: string
@ -367,7 +367,7 @@ export enum SupportUploadFileTypes {
custom = 'custom',
}
export interface UploadFileSetting {
export type UploadFileSetting = {
allowed_file_upload_methods: TransferMethod[]
allowed_file_types: SupportUploadFileTypes[]
allowed_file_extensions?: string[]
@ -375,7 +375,7 @@ export interface UploadFileSetting {
number_limits?: number
}
export interface VisionSetting {
export type VisionSetting = {
variable_selector: ValueSelector
detail: Resolution
}

View File

@ -35,6 +35,7 @@ import type { ToolNodeType } from './nodes/tool/types'
import type { IterationNodeType } from './nodes/iteration/types'
import { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { correctProvider } from '@/utils'
const WHITE = 'WHITE'
const GRAY = 'GRAY'
@ -275,6 +276,19 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
}
// legacy provider handle
if (node.data.type === BlockEnum.LLM)
(node as any).data.model.provider = correctProvider((node as any).data.model.provider)
if (node.data.type === BlockEnum.KnowledgeRetrieval && (node as any).data.multiple_retrieval_config.reranking_model)
(node as any).data.multiple_retrieval_config.reranking_model.provider = correctProvider((node as any).data.multiple_retrieval_config.reranking_model.provider)
if (node.data.type === BlockEnum.QuestionClassifier)
(node as any).data.model.provider = correctProvider((node as any).data.model.provider)
if (node.data.type === BlockEnum.ParameterExtractor)
(node as any).data.model.provider = correctProvider((node as any).data.model.provider)
return node
})
}
@ -428,7 +442,7 @@ export const genNewNodeTitleFromOld = (oldTitle: string) => {
if (match) {
const title = match[1]
const num = parseInt(match[2], 10)
const num = Number.parseInt(match[2], 10)
return `${title} (${num + 1})`
}
else {

View File

@ -45,6 +45,7 @@ const LocaleLayout = ({
data-public-maintenance-notice={process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE}
data-public-site-about={process.env.NEXT_PUBLIC_SITE_ABOUT}
data-public-text-generation-timeout-ms={process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS}
data-public-github-access-token={process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN}
>
<BrowserInitor>
<SentryInitor>

Some files were not shown because too many files have changed in this diff Show More