mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-13 01:06:03 +00:00
Feat/rag plugin (#1995)
* [issue:1933] RAG engine plugin architecture (#1967) * refactor: migrate RAG knowledge services to a plugin-oriented host service architecture. * feat(rag): phase 2 core refactor with RPC Action handlers * feat: 为 RAG 插件添加知识库创建和删除事件通知,并优化了 RAG 动作的参数传递和枚举使用。 * feat: 统一知识库管理为RAG引擎,支持动态配置并移除旧的外部知识库组件。 * refactor(rag): remove plugin_adapter, inline logic into RuntimeKnowledgeBase BREAKING CHANGE: RAGPluginAdapter has been removed. All plugin communication is now handled directly by RuntimeKnowledgeBase. Architecture change: - Before: RuntimeKnowledgeBase → RAGPluginAdapter → plugin_connector - After: RuntimeKnowledgeBase → plugin_connector (direct) Changes to kbmgr.py (RuntimeKnowledgeBase): - Remove RAGPluginAdapter import and usage - Inline plugin communication methods: - _on_kb_create(): Notify plugin when KB is created - _on_kb_delete(): Notify plugin when KB is deleted - _ingest_document(): Call plugin for document ingestion - _retrieve(): Call plugin for retrieval - _delete_document(): Call plugin to delete document - Simplify dispose(): Only notify plugin, no built-in VDB assumption Changes to base.py (KnowledgeBaseInterface): - Remove get_type() abstract method (outdated internal/external concept) - Add get_rag_engine_plugin_id() abstract method Changes to localagent.py: - Remove get_type() call - Simplify top_k retrieval from KB entity Deleted files: - pkg/rag/knowledge/plugin_adapter.py Benefits: - Reduced abstraction layer, simpler code - Plugin communication logic centralized in RuntimeKnowledgeBase - Easier to understand and maintain 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(api): remove ExternalKnowledgeBase infrastructure BREAKING CHANGE: ExternalKnowledgeBase has been completely removed. All knowledge bases are now unified under the single KnowledgeBase model, differentiated by their rag_engine_plugin_id. Deleted files: - pkg/api/http/controller/groups/knowledge/external.py (ExternalKBController with /external-bases routes) - pkg/api/http/service/external_kb.py (ExternalKnowledgeBaseService) - pkg/rag/knowledge/external.py (ExternalKnowledgeBase implementation) Modified files: - pkg/entity/persistence/rag.py: Remove ExternalKnowledgeBase SQLAlchemy table definition - pkg/core/app.py: Remove external_kb_service attribute from LangBotApplication - pkg/core/stages/build_app.py: Remove external_kb_service initialization Migration notes: - Existing external knowledge base data should be migrated manually - API consumers should use /api/v1/knowledge/bases for all KB operations - Use /api/v1/knowledge/engines to discover available RAG engines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(plugin): remove list_knowledge_retrievers from connector Remove deprecated list_knowledge_retrievers functionality from the plugin communication layer. This aligns with the SDK change that removed the LIST_KNOWLEDGE_RETRIEVERS action. Changes: - connector.py: Remove list_knowledge_retrievers() method - handler.py: Remove list_knowledge_retrievers() handler The functionality is replaced by the new /api/v1/knowledge/engines endpoint which lists available RAGEngine components with their capabilities and configuration schemas. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(service): update knowledge service with capability-based checks Replace type-based checks with capability-based checks for file operations, aligning with the unified knowledge base architecture. Changes to knowledge.py: - store_file(): Replace get_type() check with doc_ingestion capability check - delete_file(): Replace get_type() check with doc_ingestion capability check - list_rag_engines(): Remove list_knowledge_retrievers call, simplify to only list RAGEngine components (KnowledgeRetriever type removed) Changes to pipelines.py: - Minor cleanup related to knowledge base references The capability-based approach allows RAG engines to declare their supported features (doc_ingestion, chunking_config, rerank, hybrid_search) and the system responds accordingly, rather than hardcoding behavior based on internal/external type distinction. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(web): unify knowledge base UI, remove external KB components BREAKING CHANGE: The internal/external knowledge base distinction has been removed from the frontend. All knowledge bases are now displayed in a unified list, differentiated by their RAG engine. Changes to page.tsx: - Remove Tab component (内置/外置 tabs) - Remove selectedKbType state - Unified knowledge base list display - Single "Create Knowledge Base" button for all types Changes to KBDetailDialog.tsx: - Remove kbType prop - Simplify dialog logic for unified KB handling - Documents menu item conditionally shown based on doc_ingestion capability Changes to KBForm.tsx: - Remove retriever type handling code - Simplify form for unified KB creation - Dynamic form rendering based on RAG engine's creation_schema Changes to KBCardVO.ts: - Remove 'type' field from KBCardVO interface Changes to BackendClient.ts: - Remove all external KB related methods: - getExternalKnowledgeBases() - getExternalKnowledgeBase() - createExternalKnowledgeBase() - updateExternalKnowledgeBase() - deleteExternalKnowledgeBase() - retrieveFromExternalKnowledgeBase() Changes to api/index.ts: - Remove ExternalKnowledgeBase interface definition UI/UX improvements: - Users no longer need to understand internal vs external distinction - RAG engine selection is now the primary differentiator - Documents panel visibility is capability-driven (doc_ingestion) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(plugin): code review improvements for RAG handlers - Unify embed_model field naming to embedding_model_uuid only - Add structured error responses with error_type for RAG actions - Fix file_size and mime_type detection in _store_file_task - Improve error handling with detailed error context (error_type, original_error) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(rag): refactor KB dynamic form and vector manager - Frontend: Refactor Knowledge Base form using DynamicForm components. - Frontend: Remove obsolete jsonSchemaConverter utility. - Backend: Update VectorManager and PluginHandler to support new RAG architecture. - Chore: Update dependencies in pyproject.toml. * fix: code review fixes for RAG refactor - Remove DEBUG stderr outputs in handler.py - Move repeated `import json` to file top - Add warning log for unimplemented delete_by_filter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(rag): consolidate valid_fields into entity constants Define MUTABLE_FIELDS, CREATE_FIELDS, ALL_DB_FIELDS as class constants in KnowledgeBase entity to eliminate duplication. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: 将知识库获取和RAG引擎信息丰富逻辑移至知识库管理器。 * refactor(rag): introduce RAGRuntimeService and clean up plugin handler - Create RAGRuntimeService to encapsulate RAG capability implementation (Embedding, VectorOps). - Refactor PluginHandler to delegate RAG actions to RAGRuntimeService. - Move KnowledgeService enrichment and creation logic to RAGManager. - Register RAGRuntimeService in Application and BuildAppStage. - Clean up legacy code in KnowledgeService. * refactor(rag): standardize logger and fix type hints - Use self.ap.logger consistently in kbmgr.py and runtime.py, removing module-level loggers. - Fix type hints for retrieve_knowledge in handler.py and connector.py to match implementation returning dict. * refactor: 将引擎徽章的样式从 Tailwind CSS 类迁移到 CSS 模块。 * fix(web): resolve React rendering errors in plugins page - Fix missing key prop in PluginComponentList by using ternary instead of Fragment - Fix RAGEngine.name type to I18nObject and use extractI18nObject() for rendering - Preserves multi-language support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(rag): update runtime service and web components * refactor: 优化知识库设置结构并增强前端距离显示健壮性。 * fix: 处理前端距离显示中的空值。 * fix(rag): document retrieve ui and kbmgr top_k validation * 更新 uv.lock 中的 PyPI 镜像源为官方地址。 * fix: address code review issues for RAG engine plugin architecture P0 fixes: - Fix ALL_DB_FIELDS missing collection_id and emoji fields - Move rag_engine_plugin_id to CREATE_FIELDS (immutable after creation) - Fix creation_settings mutable default value (dict -> None) - Rename vector delete method to delete_by_file_id for correct semantics - Fix delete_by_filter to raise NotImplementedError instead of silent no-op - Add database migration script (dbm019) for new columns and table cleanup P1 fixes: - Clean up design-hesitation comments in connector.py - Add _parse_plugin_id() with format validation for all RAG methods - Make _retrieve() raise exceptions instead of silently returning empty results - Extract _make_rag_error_response() helper for clean error formatting - Remove unused imports from handler.py P2 fixes: - Fix runtime.py indentation inconsistencies - Simplify get_file_stream to use storage abstraction uniformly - Reduce redundant DB queries in knowledge service (extract _check_doc_capability) - Fix engines.py URL encoding: use <path:plugin_id> instead of __ replacement - Add read-only mode for engine settings in KBForm edit mode - Simplify page.tsx handleKBCardClick to pass only kbId string Co-authored-by: Cursor <cursoragent@cursor.com> * fix: address code review findings for RAG plugin architecture - Frontend: add retrieval_settings param to retrieveKnowledgeBase API call - Backend: return {uuid} from PUT knowledge base to match frontend expectation - Backend: validate query is non-empty in retrieve endpoint (400 on empty) - Backend: rename vector_delete ids→file_ids for semantic clarity, keep backward compat by accepting both 'file_ids' and 'ids' in RPC handler - Backend: ensure rag_engine.name fallback is always I18nObject-compatible dict, preventing frontend extractI18nObject from receiving plain strings - Migration: fix misleading docstring about external_kb data migration Co-authored-by: Cursor <cursoragent@cursor.com> * Update langbot-plugin version to 0.2.6 * chore: update required database version from 18 to 19 * refactor: remove unused polymorphic component framework * chore: fix lint and format issues for python and frontend * fix(plugin): remove legacy `ids` fallback in rag_vector_delete handler SDK now sends `file_ids` directly, the `ids` backward-compat fallback is no longer needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): deep review fixes for critical bugs, security and quality Critical: - Fix StorageMgr.load() -> storage_provider.load() (C1, AttributeError) - Update required_database_version 18 -> 19 (C2, migration never runs) Security: - Add path traversal validation in get_file_stream (C11) - Add vectors/ids/metadata length validation in rag_vector_upsert (C12) Logic fixes: - Legacy KBs: set capabilities to [] instead of ['doc_ingestion'] (C4) - Fix store_file return type int -> str (C5) - Fix retrieve_knowledge return [] -> {'results': []} when disabled (C6) - Re-raise exception in _on_kb_create instead of silently swallowing (C7) - Log warning when KB not found in memory during delete (C8) API fixes: - Catch ValueError as 400 in create_knowledge_base endpoint (C15) - Validate plugin_id format in engines endpoints (C16) Quality: - Remove dead if/else in migration with identical branches (C17) - Fix variable shadowing: rag_context -> rag_context_text (C18) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove unused os import to fix ruff lint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(plugin): remove PolymorphicComponent sync from LangBot side Remove sync_polymorphic_component_instances() from connector and handler, and the post-connection sync call in initialize(). This dead code synced an always-empty list of polymorphic instances that were never created. Companion change to langbot-plugin-sdk PolymorphicComponent removal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): fix vector_delete count bug and remove vestigial instance_id parameter 1. vector_delete: assign return value from delete_by_filter to count instead of silently returning 0 for filter-based deletion. 2. Remove instance_id parameter from the entire retrieve_knowledge call chain (kbmgr → connector → handler → runtime). This parameter was a remnant of the PolymorphicComponent mechanism and is no longer used — RAGEngine operates as a stateless singleton. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): 支持 creation_schema 字段级别的 editable 属性控制编辑模式可修改性 - IDynamicFormItemSchema 添加 editable 可选属性 - DynamicFormItemConfig 透传 editable 属性 - DynamicFormComponent 接收 isEditing prop,按字段 editable 值控制禁用 - KBForm 解析 editable 并传递 isEditing 给动态表单组件 - editable 未指定时默认可编辑,editable: false 时编辑模式下禁用该字段 * feat(storage): 添加 size() 抽象方法及 LocalStorage/S3 实现 支持获取存储对象大小,S3 使用 head_object 避免下载整个文件 * fix(migration): 删除 external_knowledge_bases 表前记录日志警告 - 迁移时如果表中存在数据,先 warning 日志记录避免无感数据丢失 - 添加 chunk 清理注释说明:仅对旧版非插件架构 KB 有效 * fix(web): 修复检索结果长文本撑大容器导致查询按钮不可见 KBDetailDialog 的 main 容器添加 min-w-0 overflow-x-hidden, 限制 flex-1 子容器宽度,防止 Dify RAG 长文本撑出 Dialog 边界 * fix(rag): address code review issues for plugin architecture PR - Fix SQL injection in migration helpers by using bind parameters - Move numpy import to module level in vector/mgr.py - Improve path traversal validation using posixpath.normpath - Add call_rag_retrieve to connector, eliminating duplicate plugin_id parsing in kbmgr.py _retrieve - Normalize typing style to modern dict/list/None syntax Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(web): fix prettier formatting errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(rag): update embedding handling in RuntimeConnectionHandler - Renamed RAG_EMBED_DOCUMENTS and RAG_EMBED_QUERY actions to INVOKE_EMBEDDING for clarity. - Removed embed_documents and embed_query methods from RuntimeEmbeddingModel and RAGRuntimeService. - Integrated embedding model retrieval directly in the invoke_embedding method, improving error handling for missing models. - Updated the embedding invocation logic to streamline the process and enhance error reporting. * refactor(web): replace KnowledgeRetriever with RAGEngine across frontend and tests KnowledgeRetriever component type has been removed in favor of the new RAGEngine architecture. Update all remaining references in i18n locales, plugin component icon mappings, marketplace filter, and unit tests. Addresses reviewer notes from RockChinQ on PR #1967. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): address critical bugs found in deep review - Fix path traversal bypass in runtime.py (check all path components for '..') - Use normalized path for file loading instead of raw user input - Change knowledge_bases from list to dict for O(1) lookup and race safety - Add rollback on KB creation failure (clean up DB + runtime on plugin error) - Add null check after KB update in knowledge service - Fix file extension parsing to use os.path.splitext instead of split('.') (handles multi-dot filenames like 'report.v2.pdf' correctly) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): address remaining review issues across frontend and backend Frontend: - Fix KB delete: use async/await with error handling instead of fire-and-forget - Fix capabilities null check: add optional chaining to prevent crash - Add toast.error on KB info load failure instead of silent console.error - Replace hard-coded Chinese validation message with i18n key - Replace hard-coded English error messages in DynamicFormItemComponent with i18n - Optimize document polling: stop when all documents reach terminal state - Add i18n keys (fieldRequired, loadKnowledgeBaseFailed, deleteKnowledgeBaseFailed, getKnowledgeBaseListError) to all 4 locales Backend: - Fix KB delete atomicity: delete from DB first, then notify plugin - Add RAG engine plugin existence validation before creating KB Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(rag): fix ruff formatting in kbmgr.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Junyan Qin <rockchinq@gmail.com> * chore: bump langbot-plugin to 0.3.0 (#1992) * chore: correct sdk version to 0.3.0a1 * feat: normalize rag related actions' names * refactor(rag): align IngestionContext fields with SDK changes Remove redundant `chunking_strategy` field and rename `custom_settings` to `creation_settings` to match the updated SDK entity definitions (langbot-plugin-sdk#36). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: fix ruff formatting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): enforce immutability of embedding_model_uuid and non-editable creation_settings fields Remove embedding_model_uuid from MUTABLE_FIELDS to prevent post-creation modification via API. Add backend validation for creation_settings to preserve fields marked editable:false in the plugin's creation schema. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(rag): fix ruff formatting in knowledge service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(rag): split settings into immutable creation_settings and mutable retrieval_settings - Remove standalone embedding_model_uuid and top_k columns from KB entity - Add retrieval_settings column; update MUTABLE_FIELDS/CREATE_FIELDS accordingly - Merge migration logic into dbm019 (add retrieval_settings, migrate top_k and embedding_model_uuid into JSON settings, drop old columns on PostgreSQL) - Remove _filter_creation_settings and per-field editable concept - Frontend: creation_settings fields are all disabled when editing, retrieval_settings fields are always editable via a second DynamicFormComponent - Remove editable from IDynamicFormItemSchema, DynamicFormItemConfig - Clean up KBCardVO, KnowledgeBase API type, and localagent runner Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * bugfix: if ingest_document failed,not raise exep * fix: ruff lint * refactor(rag): remove unused _get_kb_entity method from RAGRuntimeService * feat(vector): implement metadata filters for vector_search and vector_delete (#1997) Add functional metadata filter support across all 5 VDB backends using Chroma-style where syntax as the canonical format. Previously the filters parameter existed throughout the stack but was entirely ignored. - Add filter_utils.py with normalize_filter() and strip_unsupported_fields() - Implement filter in search() and add delete_by_filter() for all backends: Chroma/SeekDB (native passthrough), Qdrant (translated to models.Filter), Milvus (translated to expr string), pgvector (translated to SQLAlchemy conditions) - Milvus/pgvector limited to {text, file_id, chunk_uuid}; other fields logged and ignored - Replace delete_by_filter() NotImplementedError with backend delegation in mgr.py - Populate retrieval_context['filters'] from settings in kbmgr._retrieve() - Pass search_type/query_text/documents through handler and runtime service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(vector): fix ruff formatting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(vector): remove numpy dependency and fix SeekDB search modes - Remove numpy array conversion for query vectors; all VDB backends accept list[float] directly - Remove redundant get_or_create_collection call from upsert; backends handle collection creation internally in add_embeddings - Fix SeekDB to raise ValueError when vector dimension is unknown instead of defaulting to 384 - Use hybrid_search() for full-text and hybrid search modes in SeekDB, since pyseekdb's query() always requires embeddings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(vector): escape single quotes in SeekDB documents and metadata Document text containing apostrophes (e.g. "don't", "it's") causes SQL syntax errors in OceanBase because single quotes were not in the escape table. Add single-quote escaping and apply the escape table to the documents parameter in add_embeddings(), not just metadata. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(vector): use standard SQL escaping for single quotes in SeekDB Change single quote escaping from MySQL-style \' to standard SQL '' (doubled quote). The backslash escape is not recognized by OceanBase in NO_BACKSLASH_ESCAPES mode, causing SQL syntax errors when metadata text contains apostrophes (e.g. O'Shea in academic citations). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): persist retrieval_settings on knowledge base creation retrieval_settings was not being passed from the service layer to RAGManager.create_knowledge_base(), causing retrieval schema fields (e.g. query_rewrite) to be lost on initial KB creation. They only took effect after a subsequent edit/update. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): add show_if conditional rendering for dynamic forms Support conditional field visibility in plugin-defined forms via show_if rules (eq, neq, in operators). Fields can depend on values from the same form or cross-reference between creation and retrieval settings via externalDependentValues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): replace base64 with chunked file transfer for get_rag_file_stream Use send_file() instead of base64 encoding for returning file content in the GET_RAG_FILE_STREAM handler, avoiding memory issues with large files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(parser): add parser plugin integration and capability-aware upload UI (#2000) * feat(parser): add parser plugin integration and capability-aware upload UI Backend: add parser plugin API endpoints (list/invoke), connector and handler support for parser actions, and KB manager passthrough. Frontend: thread ragEngineCapabilities prop to FileUploadZone and use doc_parsing capability to conditionally show the RAG engine option in the parser selector. When no parser is available, show a warning prompting users to install a parser plugin. Update i18n: rename builtInParser to "Provided by RAG engine" and add noParserAvailable warning message in all 4 locales. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(parser): replace base64 with chunked file transfer and remove stale cache - Remove @alru_cache from list_parsers() and list_rag_engines() - Replace inline base64 file content with send_file/read_local_file chunked transfer pattern in parse_document and invoke_parser flows - Remove unused base64 import from kbmgr.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): add Parser component kind to plugin market UI and i18n Add Parser to kindIconMap, market filter toggle, and all 4 locale files so parser plugins are properly displayed and filterable in the plugin market, matching the existing RAGEngine treatment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(web): fix prettier formatting from merge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: rename RAGEngine to KnowledgeEngine across frontend and backend * fix(web): fix I18nObject import path in FileUploadZone and KBDoc * chore: format files involved in RAGEngine to KnowledgeEngine refactor * refactor: change rag engine to knowledge engine * fix: update langbot-plugin version to 0.3.0rc1 * chore: disable migration 20 for now --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
@@ -319,6 +319,7 @@ export default function BotForm({
|
||||
required: item.required,
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -13,20 +13,26 @@ import {
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function DynamicFormComponent({
|
||||
itemConfigList,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
onFileUploaded,
|
||||
isEditing,
|
||||
externalDependentValues,
|
||||
}: {
|
||||
itemConfigList: IDynamicFormItemSchema[];
|
||||
onSubmit?: (val: object) => unknown;
|
||||
initialValues?: Record<string, object>;
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
isEditing?: boolean;
|
||||
externalDependentValues?: Record<string, unknown>;
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 根据 itemConfigList 动态生成 zod schema
|
||||
const formSchema = z.object(
|
||||
@@ -55,6 +61,9 @@ export default function DynamicFormComponent({
|
||||
case 'llm-model-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'embedding-model-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'knowledge-base-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
@@ -81,7 +90,9 @@ export default function DynamicFormComponent({
|
||||
(fieldSchema instanceof z.ZodString ||
|
||||
fieldSchema instanceof z.ZodArray)
|
||||
) {
|
||||
fieldSchema = fieldSchema.min(1, { message: '此字段为必填项' });
|
||||
fieldSchema = fieldSchema.min(1, {
|
||||
message: t('common.fieldRequired'),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -141,6 +152,9 @@ export default function DynamicFormComponent({
|
||||
}
|
||||
}, [initialValues, form, itemConfigList]);
|
||||
|
||||
// Get reactive form values for conditional rendering
|
||||
const watchedValues = form.watch();
|
||||
|
||||
// Stable ref for onSubmit to avoid re-triggering the effect when the
|
||||
// parent passes a new closure on every render.
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
@@ -183,34 +197,75 @@ export default function DynamicFormComponent({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="space-y-4">
|
||||
{itemConfigList.map((config) => (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
{config.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
onFileUploaded={onFileUploaded}
|
||||
/>
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{itemConfigList.map((config) => {
|
||||
if (config.show_if) {
|
||||
const dependValue =
|
||||
watchedValues[
|
||||
config.show_if.field as keyof typeof watchedValues
|
||||
] !== undefined
|
||||
? watchedValues[
|
||||
config.show_if.field as keyof typeof watchedValues
|
||||
]
|
||||
: externalDependentValues?.[config.show_if.field];
|
||||
|
||||
if (
|
||||
config.show_if.operator === 'eq' &&
|
||||
dependValue !== config.show_if.value
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'neq' &&
|
||||
dependValue === config.show_if.value
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'in' &&
|
||||
Array.isArray(config.show_if.value) &&
|
||||
!config.show_if.value.includes(dependValue)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// All fields are disabled when editing (creation_settings are immutable)
|
||||
const isFieldDisabled = !!isEditing;
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
{config.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
className={
|
||||
isFieldDisabled ? 'pointer-events-none opacity-60' : ''
|
||||
}
|
||||
>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
onFileUploaded={onFileUploaded}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
LLMModel,
|
||||
Bot,
|
||||
KnowledgeBase,
|
||||
ExternalKnowledgeBase,
|
||||
ApiRespPluginSystemStatus,
|
||||
EmbeddingModel,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -51,16 +50,12 @@ export default function DynamicFormItemComponent({
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
}) {
|
||||
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [externalKnowledgeBases, setExternalKnowledgeBases] = useState<
|
||||
ExternalKnowledgeBase[]
|
||||
>([]);
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
|
||||
@@ -111,7 +106,20 @@ export default function DynamicFormItemComponent({
|
||||
setLlmModels(models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to get LLM model list: ' + err.msg);
|
||||
toast.error(t('models.getModelListError') + err.msg);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
|
||||
httpClient
|
||||
.getProviderEmbeddingModels()
|
||||
.then((resp) => {
|
||||
setEmbeddingModels(resp.models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('embedding.getModelListError') + err.msg);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
@@ -127,39 +135,11 @@ export default function DynamicFormItemComponent({
|
||||
setKnowledgeBases(resp.bases);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to get knowledge base list: ' + err.msg);
|
||||
});
|
||||
|
||||
// Fetch plugin system status
|
||||
httpClient
|
||||
.getPluginSystemStatus()
|
||||
.then((status) => {
|
||||
setPluginSystemStatus(status);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to get plugin system status:', err);
|
||||
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
|
||||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR) &&
|
||||
pluginSystemStatus?.is_enable &&
|
||||
pluginSystemStatus?.is_connected
|
||||
) {
|
||||
httpClient
|
||||
.getExternalKnowledgeBases()
|
||||
.then((resp) => {
|
||||
setExternalKnowledgeBases(resp.bases);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to get external knowledge base list:', err);
|
||||
});
|
||||
}
|
||||
}, [config.type, pluginSystemStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.BOT_SELECTOR) {
|
||||
httpClient
|
||||
@@ -168,7 +148,7 @@ export default function DynamicFormItemComponent({
|
||||
setBots(resp.bots);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to get bot list: ' + err.msg);
|
||||
toast.error(t('bots.getBotListError') + err.msg);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
@@ -304,7 +284,56 @@ export default function DynamicFormItemComponent({
|
||||
</Select>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
|
||||
// Group embedding models by provider
|
||||
const groupedEmbeddingModels = embeddingModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName = model.provider?.name || 'Unknown';
|
||||
if (!acc[providerName]) acc[providerName] = [];
|
||||
acc[providerName].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, EmbeddingModel[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedEmbeddingModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
||||
// Group KBs by Knowledge Engine name
|
||||
const kbsByEngine = knowledgeBases.reduce(
|
||||
(acc, kb) => {
|
||||
const engineName = kb.knowledge_engine?.name
|
||||
? extractI18nObject(kb.knowledge_engine.name)
|
||||
: t('knowledge.unknownEngine');
|
||||
if (!acc[engineName]) {
|
||||
acc[engineName] = [];
|
||||
}
|
||||
acc[engineName].push(kb);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof knowledgeBases>,
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
@@ -315,53 +344,45 @@ export default function DynamicFormItemComponent({
|
||||
<SelectItem value="__none__">{t('knowledge.empty')}</SelectItem>
|
||||
</SelectGroup>
|
||||
|
||||
{knowledgeBases.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('knowledge.builtIn')}</SelectLabel>
|
||||
{knowledgeBases.map((base) => (
|
||||
{Object.entries(kbsByEngine).map(([engineName, kbs]) => (
|
||||
<SelectGroup key={engineName}>
|
||||
<SelectLabel>{engineName}</SelectLabel>
|
||||
{kbs.map((base) => (
|
||||
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
|
||||
{base.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{externalKnowledgeBases.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('knowledge.external')}</SelectLabel>
|
||||
{externalKnowledgeBases.map((base) => (
|
||||
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
base.plugin_author,
|
||||
base.plugin_name,
|
||||
)}
|
||||
alt="plugin icon"
|
||||
className="w-4 h-4 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
<span>{base.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
|
||||
// Group KBs by Knowledge Engine name for multi-selector
|
||||
const multiKbsByEngine = knowledgeBases.reduce(
|
||||
(acc, kb) => {
|
||||
const engineName = kb.knowledge_engine?.name
|
||||
? extractI18nObject(kb.knowledge_engine.name)
|
||||
: t('knowledge.unknownEngine');
|
||||
if (!acc[engineName]) {
|
||||
acc[engineName] = [];
|
||||
}
|
||||
acc[engineName].push(kb);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof knowledgeBases>,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{field.value && field.value.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{field.value.map((kbId: string) => {
|
||||
const kb = knowledgeBases.find((base) => base.uuid === kbId);
|
||||
const externalKb = externalKnowledgeBases.find(
|
||||
const currentKb = knowledgeBases.find(
|
||||
(base) => base.uuid === kbId,
|
||||
);
|
||||
const currentKb = kb || externalKb;
|
||||
if (!currentKb) return null;
|
||||
|
||||
return (
|
||||
@@ -370,18 +391,17 @@ export default function DynamicFormItemComponent({
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{externalKb && (
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
externalKb.plugin_author,
|
||||
externalKb.plugin_name,
|
||||
)}
|
||||
alt="plugin icon"
|
||||
className="w-8 h-8 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{currentKb.name}</div>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{currentKb.name}
|
||||
{currentKb.knowledge_engine?.name && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
|
||||
{extractI18nObject(
|
||||
currentKb.knowledge_engine.name,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{currentKb.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{currentKb.description}
|
||||
@@ -435,13 +455,12 @@ export default function DynamicFormItemComponent({
|
||||
<DialogTitle>{t('knowledge.selectKnowledgeBases')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
|
||||
{/* Built-in Knowledge Bases */}
|
||||
{knowledgeBases.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(multiKbsByEngine).map(([engineName, kbs]) => (
|
||||
<div key={engineName} className="space-y-2">
|
||||
<div className="text-sm font-semibold text-muted-foreground px-2">
|
||||
{t('knowledge.builtIn')}
|
||||
{engineName}
|
||||
</div>
|
||||
{knowledgeBases.map((base) => {
|
||||
{kbs.map((base) => {
|
||||
const isSelected = tempSelectedKBIds.includes(
|
||||
base.uuid ?? '',
|
||||
);
|
||||
@@ -474,56 +493,7 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Knowledge Bases */}
|
||||
{externalKnowledgeBases.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-muted-foreground px-2">
|
||||
{t('knowledge.external')}
|
||||
</div>
|
||||
{externalKnowledgeBases.map((base) => {
|
||||
const isSelected = tempSelectedKBIds.includes(
|
||||
base.uuid ?? '',
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={base.uuid}
|
||||
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
||||
onClick={() => {
|
||||
const kbId = base.uuid ?? '';
|
||||
setTempSelectedKBIds((prev) =>
|
||||
prev.includes(kbId)
|
||||
? prev.filter((id) => id !== kbId)
|
||||
: [...prev, kbId],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
aria-label={`Select ${base.name}`}
|
||||
/>
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
base.plugin_author,
|
||||
base.plugin_name,
|
||||
)}
|
||||
alt="plugin icon"
|
||||
className="w-8 h-8 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{base.name}</div>
|
||||
{base.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{base.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
IDynamicFormItemSchema,
|
||||
DynamicFormItemType,
|
||||
IDynamicFormItemOption,
|
||||
IShowIfCondition,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
@@ -14,6 +15,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
type: DynamicFormItemType;
|
||||
description?: I18nObject;
|
||||
options?: IDynamicFormItemOption[];
|
||||
show_if?: IShowIfCondition;
|
||||
|
||||
constructor(params: IDynamicFormItemSchema) {
|
||||
this.id = params.id;
|
||||
@@ -24,6 +26,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
this.type = params.type;
|
||||
this.description = params.description;
|
||||
this.options = params.options;
|
||||
this.show_if = params.show_if;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,18 +21,16 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
// import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
|
||||
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
|
||||
import KBRetrieve from '@/app/home/knowledge/components/kb-retrieve/KBRetrieve';
|
||||
import ExternalKBForm from '@/app/home/knowledge/components/external-kb-form/ExternalKBForm';
|
||||
import ExternalKBRetrieve from '@/app/home/knowledge/components/kb-retrieve/ExternalKBRetrieve';
|
||||
import KBRetrieveGeneric from '@/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric';
|
||||
|
||||
interface KBDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
kbId?: string;
|
||||
kbType: 'builtin' | 'external';
|
||||
onFormCancel: () => void;
|
||||
onKbDeleted: () => void;
|
||||
onNewKbCreated: (kbId: string) => void;
|
||||
@@ -43,7 +41,6 @@ export default function KBDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
kbId: propKbId,
|
||||
kbType,
|
||||
onFormCancel,
|
||||
onKbDeleted,
|
||||
onNewKbCreated,
|
||||
@@ -53,13 +50,39 @@ export default function KBDetailDialog({
|
||||
const [kbId, setKbId] = useState<string | undefined>(propKbId);
|
||||
const [activeMenu, setActiveMenu] = useState('metadata');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setKbId(propKbId);
|
||||
setActiveMenu('metadata');
|
||||
if (propKbId) {
|
||||
loadKbInfo(propKbId);
|
||||
} else {
|
||||
setKbInfo(null);
|
||||
}
|
||||
}, [propKbId, open]);
|
||||
|
||||
// Build menu based on KB type
|
||||
async function loadKbInfo(id: string) {
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBase(id);
|
||||
setKbInfo(resp.base);
|
||||
} catch (e) {
|
||||
console.error('Failed to load KB info:', e);
|
||||
toast.error(t('knowledge.loadKnowledgeBaseFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this KB supports document management
|
||||
const hasDocumentCapability = (): boolean => {
|
||||
if (!kbInfo || !kbInfo.knowledge_engine) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
kbInfo.knowledge_engine.capabilities?.includes('doc_ingestion') ?? false
|
||||
);
|
||||
};
|
||||
|
||||
// Build menu based on KB capabilities
|
||||
const menu = [
|
||||
{
|
||||
key: 'metadata',
|
||||
@@ -74,8 +97,8 @@ export default function KBDetailDialog({
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// Only show documents for builtin KB
|
||||
...(kbType === 'builtin'
|
||||
// Show documents only if capability is present
|
||||
...(hasDocumentCapability()
|
||||
? [
|
||||
{
|
||||
key: 'documents',
|
||||
@@ -107,66 +130,49 @@ export default function KBDetailDialog({
|
||||
},
|
||||
];
|
||||
|
||||
const confirmDelete = () => {
|
||||
const deletePromise =
|
||||
kbType === 'builtin'
|
||||
? httpClient.deleteKnowledgeBase(kbId ?? '')
|
||||
: httpClient.deleteExternalKnowledgeBase(kbId ?? '');
|
||||
|
||||
deletePromise.then(() => {
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await httpClient.deleteKnowledgeBase(kbId ?? '');
|
||||
onKbDeleted();
|
||||
});
|
||||
setShowDeleteConfirm(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete KB:', e);
|
||||
toast.error(t('knowledge.deleteKnowledgeBaseFailed'));
|
||||
} finally {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieve function
|
||||
const retrieveFunction = async (id: string, query: string) => {
|
||||
return await httpClient.retrieveKnowledgeBase(id, query);
|
||||
};
|
||||
|
||||
if (!kbId) {
|
||||
// new kb
|
||||
// New KB creation
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
|
||||
<main className="flex flex-1 flex-col h-[70vh]">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>
|
||||
{kbType === 'builtin'
|
||||
? t('knowledge.createKnowledgeBase')
|
||||
: t('knowledge.addExternal')}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{t('knowledge.createKnowledgeBase')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
{kbType === 'builtin' ? (
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={onNewKbCreated}
|
||||
onKbUpdated={onKbUpdated}
|
||||
/>
|
||||
) : (
|
||||
<ExternalKBForm
|
||||
initKBId={undefined}
|
||||
onFormSubmit={() => onOpenChange(false)}
|
||||
onKBDeleted={() => {}}
|
||||
onNewKBCreated={onNewKbCreated}
|
||||
/>
|
||||
)}
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={onNewKbCreated}
|
||||
onKbUpdated={onKbUpdated}
|
||||
/>
|
||||
</div>
|
||||
{activeMenu === 'metadata' && (
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
form={kbType === 'builtin' ? 'kb-form' : 'external-kb-form'}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</main>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -205,7 +211,7 @@ export default function KBDetailDialog({
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
<main className="flex flex-1 flex-col h-[75vh]">
|
||||
<main className="flex flex-1 flex-col h-[75vh] min-w-0 overflow-x-hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>
|
||||
{activeMenu === 'metadata'
|
||||
@@ -216,33 +222,28 @@ export default function KBDetailDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
{activeMenu === 'metadata' &&
|
||||
(kbType === 'builtin' ? (
|
||||
<KBForm
|
||||
initKbId={kbId}
|
||||
onNewKbCreated={onNewKbCreated}
|
||||
onKbUpdated={onKbUpdated}
|
||||
/>
|
||||
) : (
|
||||
<ExternalKBForm
|
||||
initKBId={kbId}
|
||||
onFormSubmit={() => onOpenChange(false)}
|
||||
onKBDeleted={() => {
|
||||
onKbDeleted();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onNewKBCreated={onNewKbCreated}
|
||||
/>
|
||||
))}
|
||||
{activeMenu === 'documents' && kbType === 'builtin' && (
|
||||
<KBDoc kbId={kbId} />
|
||||
{activeMenu === 'metadata' && (
|
||||
<KBForm
|
||||
initKbId={kbId}
|
||||
onNewKbCreated={onNewKbCreated}
|
||||
onKbUpdated={onKbUpdated}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'documents' && hasDocumentCapability() && (
|
||||
<KBDoc
|
||||
kbId={kbId}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={
|
||||
kbInfo?.knowledge_engine?.capabilities
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'retrieve' && (
|
||||
<KBRetrieveGeneric
|
||||
kbId={kbId}
|
||||
retrieveFunction={retrieveFunction}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'retrieve' &&
|
||||
(kbType === 'builtin' ? (
|
||||
<KBRetrieve kbId={kbId} />
|
||||
) : (
|
||||
<ExternalKBRetrieve kbId={kbId} />
|
||||
))}
|
||||
</div>
|
||||
{activeMenu === 'metadata' && (
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
@@ -254,12 +255,7 @@ export default function KBDetailDialog({
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={
|
||||
kbType === 'builtin' ? 'kb-form' : 'external-kb-form'
|
||||
}
|
||||
>
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -277,7 +273,7 @@ export default function KBDetailDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ExternalKBCardVO } from '@/app/home/knowledge/components/external-kb-card/ExternalKBCardVO';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from '../kb-card/KBCard.module.css';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
export default function ExternalKBCard({
|
||||
kbCardVO,
|
||||
}: {
|
||||
kbCardVO: ExternalKBCardVO;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`${styles.cardContainer}`}>
|
||||
<div className={`${styles.basicInfoContainer}`}>
|
||||
<div className={`${styles.iconBasicInfoContainer}`}>
|
||||
{/* Emoji with plugin icon badge */}
|
||||
<div className="relative">
|
||||
<div className={`${styles.iconEmoji}`}>
|
||||
{kbCardVO.emoji || '🔗'}
|
||||
</div>
|
||||
{/* Plugin icon badge at bottom right */}
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
kbCardVO.pluginAuthor,
|
||||
kbCardVO.pluginName,
|
||||
)}
|
||||
alt="plugin icon"
|
||||
className="absolute -bottom-1 -right-1 w-5 h-5 rounded-[20%]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.basicInfoNameContainer}`}>
|
||||
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
|
||||
{kbCardVO.name}
|
||||
</div>
|
||||
<div className={`${styles.basicInfoDescriptionText}`}>
|
||||
{kbCardVO.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>
|
||||
<svg
|
||||
className={`${styles.basicInfoUpdateTimeIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z"></path>
|
||||
</svg>
|
||||
<div className={`${styles.basicInfoUpdateTimeText}`}>
|
||||
{t('knowledge.updateTime')}
|
||||
{kbCardVO.lastUpdatedTimeAgo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
export class ExternalKBCardVO {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
retrieverName: string;
|
||||
retrieverConfig: Record<string, unknown>;
|
||||
lastUpdatedTimeAgo: string;
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
|
||||
constructor({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
emoji,
|
||||
retrieverName,
|
||||
retrieverConfig,
|
||||
lastUpdatedTimeAgo,
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
retrieverName: string;
|
||||
retrieverConfig: Record<string, unknown>;
|
||||
lastUpdatedTimeAgo: string;
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
}) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.emoji = emoji;
|
||||
this.retrieverName = retrieverName;
|
||||
this.retrieverConfig = retrieverConfig;
|
||||
this.lastUpdatedTimeAgo = lastUpdatedTimeAgo;
|
||||
this.pluginAuthor = pluginAuthor;
|
||||
this.pluginName = pluginName;
|
||||
}
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UUID } from 'uuidjs';
|
||||
|
||||
import {
|
||||
DynamicFormItemConfig,
|
||||
getDefaultValues,
|
||||
parseDynamicFormItemType,
|
||||
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ExternalKnowledgeBase } from '@/app/infra/entities/api';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
// Form schema
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, { message: t('knowledge.nameRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
plugin_author: z.string().min(1, { message: 'Please select a retriever' }),
|
||||
plugin_name: z.string().min(1, { message: 'Please select a retriever' }),
|
||||
retriever_name: z.string().min(1, { message: 'Please select a retriever' }),
|
||||
retriever_config: z.record(z.string(), z.any()),
|
||||
});
|
||||
|
||||
// Retriever information interface
|
||||
interface RetrieverInfo {
|
||||
plugin_author: string;
|
||||
plugin_name: string;
|
||||
retriever_name: string;
|
||||
retriever_description: I18nObject;
|
||||
manifest: {
|
||||
manifest?: {
|
||||
metadata?: {
|
||||
label?: I18nObject;
|
||||
description?: I18nObject;
|
||||
};
|
||||
spec?: {
|
||||
config?: IDynamicFormItemSchema[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ExternalKBFormProps {
|
||||
initKBId?: string;
|
||||
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
|
||||
onKBDeleted: () => void;
|
||||
onNewKBCreated: (kbId: string) => void;
|
||||
}
|
||||
|
||||
export default function ExternalKBForm({
|
||||
initKBId,
|
||||
onFormSubmit,
|
||||
onKBDeleted,
|
||||
onNewKBCreated,
|
||||
}: ExternalKBFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
// Form setup
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
emoji: '🔗',
|
||||
plugin_author: '',
|
||||
plugin_name: '',
|
||||
retriever_name: '',
|
||||
retriever_config: {},
|
||||
},
|
||||
});
|
||||
|
||||
// State management
|
||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
||||
const [availableRetrievers, setAvailableRetrievers] = useState<
|
||||
RetrieverInfo[]
|
||||
>([]);
|
||||
const [retrieverNameToConfigMap, setRetrieverNameToConfigMap] = useState(
|
||||
new Map<string, IDynamicFormItemSchema[]>(),
|
||||
);
|
||||
const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);
|
||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||
IDynamicFormItemSchema[]
|
||||
>([]);
|
||||
|
||||
// Initialize form when initKBId changes
|
||||
useEffect(() => {
|
||||
loadFormData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initKBId]);
|
||||
|
||||
/**
|
||||
* Load form data: initialize retrievers list and load KB config if editing
|
||||
*/
|
||||
async function loadFormData() {
|
||||
const configMap = await loadAvailableRetrievers();
|
||||
|
||||
if (initKBId) {
|
||||
// Edit mode: load existing KB configuration
|
||||
try {
|
||||
const kbConfig = await loadKBConfig(initKBId);
|
||||
// Set form values
|
||||
form.setValue('name', kbConfig.name);
|
||||
form.setValue('description', kbConfig.description || '');
|
||||
form.setValue('emoji', kbConfig.emoji || '🔗');
|
||||
form.setValue('plugin_author', kbConfig.plugin_author);
|
||||
form.setValue('plugin_name', kbConfig.plugin_name);
|
||||
form.setValue('retriever_name', kbConfig.retriever_name);
|
||||
form.setValue('retriever_config', kbConfig.retriever_config);
|
||||
|
||||
// Load dynamic form for the selected retriever
|
||||
const fullName = `${kbConfig.plugin_author}/${kbConfig.plugin_name}/${kbConfig.retriever_name}`;
|
||||
loadDynamicFormConfig(fullName, configMap);
|
||||
} catch (err) {
|
||||
toast.error('Failed to load KB config: ' + (err as Error).message);
|
||||
}
|
||||
} else {
|
||||
// Create mode: reset form
|
||||
form.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available retrievers from API and build config map
|
||||
*/
|
||||
async function loadAvailableRetrievers(): Promise<
|
||||
Map<string, IDynamicFormItemSchema[]>
|
||||
> {
|
||||
const retrieversRes = await httpClient.listKnowledgeRetrievers();
|
||||
setAvailableRetrievers((retrieversRes.retrievers || []) as RetrieverInfo[]);
|
||||
|
||||
// Build retriever name to config map
|
||||
const configMap = new Map<string, IDynamicFormItemSchema[]>();
|
||||
((retrieversRes.retrievers || []) as RetrieverInfo[]).forEach(
|
||||
(retriever) => {
|
||||
const fullName = `${retriever.plugin_author}/${retriever.plugin_name}/${retriever.retriever_name}`;
|
||||
const configSchema = retriever.manifest?.manifest?.spec?.config || [];
|
||||
|
||||
configMap.set(
|
||||
fullName,
|
||||
configSchema.map(
|
||||
(item) =>
|
||||
new DynamicFormItemConfig({
|
||||
default: item.default,
|
||||
id: UUID.generate(),
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
required: item.required,
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
setRetrieverNameToConfigMap(configMap);
|
||||
return configMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load KB configuration from API
|
||||
*/
|
||||
async function loadKBConfig(
|
||||
kbId: string,
|
||||
): Promise<z.infer<typeof formSchema>> {
|
||||
const res = await httpClient.getExternalKnowledgeBase(kbId);
|
||||
const kb = res.base;
|
||||
return {
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji || '🔗',
|
||||
plugin_author: kb.plugin_author,
|
||||
plugin_name: kb.plugin_name,
|
||||
retriever_name: kb.retriever_name,
|
||||
retriever_config: kb.retriever_config || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dynamic form configuration for selected retriever
|
||||
* @param fullRetrieverName - Full retriever name in format: plugin_author/plugin_name/retriever_name
|
||||
* @param configMapOverride - Optional config map to use (for initial load)
|
||||
*/
|
||||
function loadDynamicFormConfig(
|
||||
fullRetrieverName: string,
|
||||
configMapOverride?: Map<string, IDynamicFormItemSchema[]>,
|
||||
) {
|
||||
if (!fullRetrieverName) {
|
||||
setShowDynamicForm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use provided config map or fall back to state
|
||||
const configMap = configMapOverride || retrieverNameToConfigMap;
|
||||
const configList = configMap.get(fullRetrieverName);
|
||||
|
||||
if (configList && configList.length > 0) {
|
||||
setDynamicFormConfigList(configList);
|
||||
setShowDynamicForm(true);
|
||||
|
||||
// Only reset to default values when manually selecting (not initial load)
|
||||
if (!configMapOverride) {
|
||||
form.setValue('retriever_config', getDefaultValues(configList));
|
||||
}
|
||||
} else {
|
||||
setShowDynamicForm(false);
|
||||
if (!configMapOverride) {
|
||||
form.setValue('retriever_config', {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle retriever selection change
|
||||
*/
|
||||
function handleRetrieverSelect(fullRetrieverName: string) {
|
||||
if (!fullRetrieverName) {
|
||||
setShowDynamicForm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse and update form fields
|
||||
const parts = fullRetrieverName.split('/');
|
||||
if (parts.length === 3) {
|
||||
form.setValue('plugin_author', parts[0]);
|
||||
form.setValue('plugin_name', parts[1]);
|
||||
form.setValue('retriever_name', parts[2]);
|
||||
}
|
||||
|
||||
// Load dynamic form configuration
|
||||
loadDynamicFormConfig(fullRetrieverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission (create or update)
|
||||
*/
|
||||
function handleFormSubmit() {
|
||||
const formData: ExternalKnowledgeBase = {
|
||||
name: form.getValues().name,
|
||||
description: form.getValues().description || '',
|
||||
emoji: form.getValues().emoji,
|
||||
plugin_author: form.getValues().plugin_author,
|
||||
plugin_name: form.getValues().plugin_name,
|
||||
retriever_name: form.getValues().retriever_name,
|
||||
retriever_config: form.getValues().retriever_config,
|
||||
};
|
||||
|
||||
if (initKBId) {
|
||||
// Update existing KB
|
||||
httpClient
|
||||
.updateExternalKnowledgeBase(initKBId, { ...formData, uuid: initKBId })
|
||||
.then(() => {
|
||||
onFormSubmit(form.getValues());
|
||||
toast.success(t('knowledge.updateExternalSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to update KB: ' + err.msg);
|
||||
});
|
||||
} else {
|
||||
// Create new KB
|
||||
httpClient
|
||||
.createExternalKnowledgeBase(formData)
|
||||
.then((res) => {
|
||||
toast.success(t('knowledge.createExternalSuccess'));
|
||||
onNewKBCreated(res.uuid);
|
||||
form.reset();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to create KB: ' + err.msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle KB deletion
|
||||
*/
|
||||
function handleDelete() {
|
||||
if (!initKBId) return;
|
||||
|
||||
httpClient
|
||||
.deleteExternalKnowledgeBase(initKBId)
|
||||
.then(() => {
|
||||
onKBDeleted();
|
||||
toast.success(t('knowledge.deleteExternalSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to delete KB: ' + err.msg);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retriever label with i18n support
|
||||
*/
|
||||
function getRetrieverLabel(fullName: string): string {
|
||||
const retriever = availableRetrievers.find(
|
||||
(r) =>
|
||||
`${r.plugin_author}/${r.plugin_name}/${r.retriever_name}` === fullName,
|
||||
);
|
||||
return retriever?.manifest?.manifest?.metadata?.label
|
||||
? extractI18nObject(retriever.manifest.manifest.metadata.label)
|
||||
: fullName;
|
||||
}
|
||||
|
||||
// Compute full retriever name for display
|
||||
const currentRetrieverFullName =
|
||||
form.watch('plugin_author') &&
|
||||
form.watch('plugin_name') &&
|
||||
form.watch('retriever_name')
|
||||
? `${form.watch('plugin_author')}/${form.watch(
|
||||
'plugin_name',
|
||||
)}/${form.watch('retriever_name')}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={showDeleteConfirmModal}
|
||||
onOpenChange={setShowDeleteConfirmModal}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
{t('knowledge.deleteConfirmation')}
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirmModal(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
handleDelete();
|
||||
setShowDeleteConfirmModal(false);
|
||||
}}
|
||||
>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Main Form */}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="external-kb-form"
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* KB Name and Emoji in same row */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('knowledge.kbName')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* KB Description */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Retriever Selector */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retriever_name"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.retriever')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={handleRetrieverSelect}
|
||||
value={currentRetrieverFullName}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue
|
||||
placeholder={t('knowledge.selectRetriever')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
<SelectGroup>
|
||||
{availableRetrievers.map((retriever) => {
|
||||
const fullName = `${retriever.plugin_author}/${retriever.plugin_name}/${retriever.retriever_name}`;
|
||||
const label = retriever.manifest?.manifest?.metadata
|
||||
?.label
|
||||
? extractI18nObject(
|
||||
retriever.manifest.manifest.metadata.label,
|
||||
)
|
||||
: retriever.retriever_name;
|
||||
const description = extractI18nObject(
|
||||
retriever.retriever_description,
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
key={fullName}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<SelectItem value={fullName}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-80 data-[state=open]:animate-none"
|
||||
align="end"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
retriever.plugin_author,
|
||||
retriever.plugin_name,
|
||||
)}
|
||||
alt="plugin icon"
|
||||
className="w-10 h-10 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm">
|
||||
{label}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{retriever.plugin_author} /{' '}
|
||||
{retriever.plugin_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.retrieverInstallInfo')}{' '}
|
||||
<a
|
||||
href="https://space.langbot.app/market?category=KnowledgeRetriever"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline hover:no-underline"
|
||||
>
|
||||
{t('knowledge.retrieverMarketLink')}
|
||||
</a>
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Selected Retriever Card */}
|
||||
{currentRetrieverFullName && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg border">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
form.watch('plugin_author'),
|
||||
form.watch('plugin_name'),
|
||||
)}
|
||||
alt="plugin icon"
|
||||
className="w-12 h-12 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-medium">
|
||||
{getRetrieverLabel(currentRetrieverFullName)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{form.watch('plugin_author')} / {form.watch('plugin_name')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Retriever Configuration Form */}
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-medium">
|
||||
{t('knowledge.retrieverConfiguration')}
|
||||
</div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
initialValues={form.watch('retriever_config')}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('retriever_config', values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,3 +169,18 @@
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.engineBadge {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-color: #f3e8ff;
|
||||
color: #7e22ce;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .engineBadge {
|
||||
background-color: #581c87;
|
||||
color: #d8b4fe;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,21 @@ import styles from './KBCard.module.css';
|
||||
|
||||
export default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`${styles.cardContainer}`}>
|
||||
<div className={`${styles.basicInfoContainer}`}>
|
||||
<div className={`${styles.iconBasicInfoContainer}`}>
|
||||
<div className={`${styles.iconEmoji}`}>{kbCardVO.emoji || '📚'}</div>
|
||||
<div className={`${styles.basicInfoNameContainer}`}>
|
||||
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
|
||||
{kbCardVO.name}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
|
||||
{kbCardVO.name}
|
||||
</div>
|
||||
{/* Engine badge */}
|
||||
<span className={styles.engineBadge}>
|
||||
{kbCardVO.getEngineName()}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`${styles.basicInfoDescriptionText}`}>
|
||||
{kbCardVO.description}
|
||||
|
||||
@@ -1,29 +1,52 @@
|
||||
import { KnowledgeEngineInfo } from '@/app/infra/entities/api';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
export interface IKnowledgeBaseVO {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
embeddingModelUUID: string;
|
||||
top_k: number;
|
||||
lastUpdatedTimeAgo: string;
|
||||
emoji?: string;
|
||||
ragEngine?: KnowledgeEngineInfo;
|
||||
ragEnginePluginId?: string;
|
||||
}
|
||||
|
||||
export class KnowledgeBaseVO implements IKnowledgeBaseVO {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
embeddingModelUUID: string;
|
||||
top_k: number;
|
||||
lastUpdatedTimeAgo: string;
|
||||
emoji?: string;
|
||||
ragEngine?: KnowledgeEngineInfo;
|
||||
ragEnginePluginId?: string;
|
||||
|
||||
constructor(props: IKnowledgeBaseVO) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.description = props.description;
|
||||
this.embeddingModelUUID = props.embeddingModelUUID;
|
||||
this.top_k = props.top_k;
|
||||
this.lastUpdatedTimeAgo = props.lastUpdatedTimeAgo;
|
||||
this.emoji = props.emoji;
|
||||
this.ragEngine = props.ragEngine;
|
||||
this.ragEnginePluginId = props.ragEnginePluginId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this KB supports document management
|
||||
*/
|
||||
hasDocumentCapability(): boolean {
|
||||
if (!this.ragEngine) {
|
||||
return false;
|
||||
}
|
||||
return this.ragEngine.capabilities.includes('doc_ingestion');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for the Knowledge Engine
|
||||
*/
|
||||
getEngineName(): string {
|
||||
if (!this.ragEngine) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return extractI18nObject(this.ragEngine.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ParserInfo } from '@/app/infra/entities/api';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
kbId: string;
|
||||
ragEngineName?: I18nObject;
|
||||
ragEngineCapabilities?: string[];
|
||||
onUploadSuccess: () => void;
|
||||
onUploadError: (error: string) => void;
|
||||
}
|
||||
|
||||
export default function FileUploadZone({
|
||||
kbId,
|
||||
ragEngineName,
|
||||
ragEngineCapabilities,
|
||||
onUploadSuccess,
|
||||
onUploadError,
|
||||
}: FileUploadZoneProps) {
|
||||
@@ -19,17 +34,49 @@ export default function FileUploadZone({
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (isUploading) return;
|
||||
// Parser selection state
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [availableParsers, setAvailableParsers] = useState<ParserInfo[]>([]);
|
||||
const [selectedParser, setSelectedParser] = useState<string>('builtin');
|
||||
const [loadingParsers, setLoadingParsers] = useState(false);
|
||||
|
||||
// Check file size (10MB limit)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(t('knowledge.documentsTab.fileSizeExceeded'));
|
||||
return;
|
||||
}
|
||||
// Whether the Knowledge Engine natively supports document parsing.
|
||||
// This is a coarse-grained capability check rather than per-MIME-type filtering.
|
||||
// Fine-grained MIME type declaration (e.g. supported_parse_mime_types on the engine)
|
||||
// would require changes across the SDK, backend, and frontend prop chain;
|
||||
// using an engine-level capability flag keeps the change minimal.
|
||||
const ragEngineCanParse =
|
||||
ragEngineCapabilities?.includes('doc_parsing') ?? false;
|
||||
|
||||
// When a file is selected, check for available parsers
|
||||
useEffect(() => {
|
||||
if (!pendingFile) return;
|
||||
|
||||
const mimeType = pendingFile.type || undefined;
|
||||
setLoadingParsers(true);
|
||||
httpClient
|
||||
.listParsers(mimeType)
|
||||
.then((resp) => {
|
||||
const parsers = resp.parsers || [];
|
||||
setAvailableParsers(parsers);
|
||||
if (ragEngineCanParse) {
|
||||
setSelectedParser('builtin');
|
||||
} else if (parsers.length > 0) {
|
||||
setSelectedParser(parsers[0].plugin_id);
|
||||
} else {
|
||||
setSelectedParser('');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAvailableParsers([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingParsers(false);
|
||||
});
|
||||
}, [pendingFile, ragEngineCanParse]);
|
||||
|
||||
const doUpload = useCallback(
|
||||
async (file: File, parserPluginId?: string) => {
|
||||
setIsUploading(true);
|
||||
const toastId = toast.loading(t('knowledge.documentsTab.uploadingFile'));
|
||||
|
||||
@@ -37,8 +84,12 @@ export default function FileUploadZone({
|
||||
// Step 1: Upload file to server
|
||||
const uploadResult = await httpClient.uploadDocumentFile(file);
|
||||
|
||||
// Step 2: Associate file with knowledge base
|
||||
await httpClient.uploadKnowledgeBaseFile(kbId, uploadResult.file_id);
|
||||
// Step 2: Associate file with knowledge base (with optional parser)
|
||||
await httpClient.uploadKnowledgeBaseFile(
|
||||
kbId,
|
||||
uploadResult.file_id,
|
||||
parserPluginId,
|
||||
);
|
||||
|
||||
toast.success(t('knowledge.documentsTab.uploadSuccess'), {
|
||||
id: toastId,
|
||||
@@ -51,11 +102,65 @@ export default function FileUploadZone({
|
||||
onUploadError(errorMessage);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setPendingFile(null);
|
||||
setAvailableParsers([]);
|
||||
setSelectedParser('builtin');
|
||||
}
|
||||
},
|
||||
[kbId, isUploading, onUploadSuccess, onUploadError, t],
|
||||
[kbId, onUploadSuccess, onUploadError, t],
|
||||
);
|
||||
|
||||
const handleFileSelected = useCallback(
|
||||
async (file: File) => {
|
||||
if (isUploading) return;
|
||||
|
||||
// Check file size (10MB limit)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(t('knowledge.documentsTab.fileSizeExceeded'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loadingParsers=true BEFORE pendingFile so both state updates
|
||||
// batch together in the same render. This prevents the auto-upload
|
||||
// effect from firing before parser fetch completes.
|
||||
setLoadingParsers(true);
|
||||
setPendingFile(file);
|
||||
},
|
||||
[isUploading, t],
|
||||
);
|
||||
|
||||
// Auto-upload if Knowledge Engine can parse and no external parsers available
|
||||
useEffect(() => {
|
||||
if (
|
||||
pendingFile &&
|
||||
!loadingParsers &&
|
||||
ragEngineCanParse &&
|
||||
availableParsers.length === 0
|
||||
) {
|
||||
doUpload(pendingFile);
|
||||
}
|
||||
}, [
|
||||
pendingFile,
|
||||
loadingParsers,
|
||||
ragEngineCanParse,
|
||||
availableParsers,
|
||||
doUpload,
|
||||
]);
|
||||
|
||||
const handleConfirmUpload = useCallback(() => {
|
||||
if (!pendingFile) return;
|
||||
const parserPluginId =
|
||||
selectedParser === 'builtin' ? undefined : selectedParser;
|
||||
doUpload(pendingFile, parserPluginId);
|
||||
}, [pendingFile, selectedParser, doUpload]);
|
||||
|
||||
const handleCancelUpload = useCallback(() => {
|
||||
setPendingFile(null);
|
||||
setAvailableParsers([]);
|
||||
setSelectedParser('builtin');
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
@@ -73,79 +178,145 @@ export default function FileUploadZone({
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
handleUpload(files[0]);
|
||||
handleFileSelected(files[0]);
|
||||
}
|
||||
},
|
||||
[handleUpload],
|
||||
[handleFileSelected],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleUpload(files[0]);
|
||||
handleFileSelected(files[0]);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
},
|
||||
[handleUpload],
|
||||
[handleFileSelected],
|
||||
);
|
||||
|
||||
// Show parser selection UI when there are choices to make, or when no parser is available
|
||||
const showParserSelector =
|
||||
pendingFile &&
|
||||
!loadingParsers &&
|
||||
(availableParsers.length > 0 || !ragEngineCanParse);
|
||||
|
||||
const noParserAvailable = !ragEngineCanParse && availableParsers.length === 0;
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-4 text-center transition-colors
|
||||
${
|
||||
isDragOver
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
${isUploading ? 'opacity-50 pointer-events-none' : ''}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.html,.zip"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
<label htmlFor="file-upload" className="cursor-pointer block">
|
||||
<div className="space-y-2">
|
||||
<div className="mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{showParserSelector ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{pendingFile.name}
|
||||
</p>
|
||||
{noParserAvailable ? (
|
||||
<div className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{t('knowledge.documentsTab.noParserAvailable')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('knowledge.documentsTab.selectParser')}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedParser}
|
||||
onValueChange={setSelectedParser}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
{isUploading
|
||||
? t('knowledge.documentsTab.uploading')
|
||||
: t('knowledge.documentsTab.dragAndDrop')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1 dark:text-gray-400">
|
||||
{t('knowledge.documentsTab.supportedFormats')}
|
||||
</p>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ragEngineCanParse && (
|
||||
<SelectItem value="builtin">
|
||||
{ragEngineName
|
||||
? extractI18nObject(ragEngineName)
|
||||
: t('knowledge.documentsTab.builtInParser')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{availableParsers.map((parser) => (
|
||||
<SelectItem
|
||||
key={parser.plugin_id}
|
||||
value={parser.plugin_id}
|
||||
>
|
||||
{extractI18nObject(parser.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCancelUpload}>
|
||||
{t('knowledge.documentsTab.cancelUpload')}
|
||||
</Button>
|
||||
{!noParserAvailable && (
|
||||
<Button size="sm" onClick={handleConfirmUpload}>
|
||||
{t('knowledge.documentsTab.confirmUpload')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-4 text-center transition-colors
|
||||
${
|
||||
isDragOver
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
${isUploading || loadingParsers ? 'opacity-50 pointer-events-none' : ''}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.html,.zip"
|
||||
disabled={isUploading || loadingParsers}
|
||||
/>
|
||||
|
||||
<label htmlFor="file-upload" className="cursor-pointer block">
|
||||
<div className="space-y-2">
|
||||
<div className="mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
{isUploading
|
||||
? t('knowledge.documentsTab.uploading')
|
||||
: t('knowledge.documentsTab.dragAndDrop')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1 dark:text-gray-400">
|
||||
{t('knowledge.documentsTab.supportedFormats')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,48 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { KnowledgeBaseFile } from '@/app/infra/entities/api';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { columns, DocumentFile } from './documents/columns';
|
||||
import { DataTable } from './documents/data-table';
|
||||
import FileUploadZone from './FileUploadZone';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function KBDoc({ kbId }: { kbId: string }) {
|
||||
export default function KBDoc({
|
||||
kbId,
|
||||
ragEngineName,
|
||||
ragEngineCapabilities,
|
||||
}: {
|
||||
kbId: string;
|
||||
ragEngineName?: I18nObject;
|
||||
ragEngineCapabilities?: string[];
|
||||
}) {
|
||||
const [documentsList, setDocumentsList] = useState<DocumentFile[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getDocumentsList();
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
getDocumentsList();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
const getDocumentsList = useCallback(async () => {
|
||||
const resp = await httpClient.getKnowledgeBaseFiles(kbId);
|
||||
const files = resp.files.map((file: KnowledgeBaseFile) => ({
|
||||
uuid: file.uuid,
|
||||
name: file.file_name,
|
||||
status: file.status,
|
||||
}));
|
||||
setDocumentsList(files);
|
||||
return files;
|
||||
}, [kbId]);
|
||||
|
||||
async function getDocumentsList() {
|
||||
const resp = await httpClient.getKnowledgeBaseFiles(kbId);
|
||||
setDocumentsList(
|
||||
resp.files.map((file: KnowledgeBaseFile) => {
|
||||
return {
|
||||
uuid: file.uuid,
|
||||
name: file.file_name,
|
||||
status: file.status,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
const startPolling = useCallback(() => {
|
||||
if (intervalRef.current) return;
|
||||
intervalRef.current = setInterval(() => {
|
||||
getDocumentsList().then((files) => {
|
||||
const allDone =
|
||||
files.length > 0 &&
|
||||
files.every(
|
||||
(doc: DocumentFile) =>
|
||||
doc.status === 'completed' || doc.status === 'failed',
|
||||
);
|
||||
if (allDone && intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
}, [getDocumentsList]);
|
||||
|
||||
useEffect(() => {
|
||||
getDocumentsList().then((files) => {
|
||||
const hasProcessing = files.some(
|
||||
(doc: DocumentFile) =>
|
||||
doc.status !== 'completed' && doc.status !== 'failed',
|
||||
);
|
||||
if (hasProcessing) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [kbId, getDocumentsList, startPolling]);
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
// Refresh document list after successful upload
|
||||
getDocumentsList();
|
||||
startPolling();
|
||||
};
|
||||
|
||||
const handleUploadError = (error: string) => {
|
||||
// Error messages are already handled by toast in FileUploadZone component
|
||||
console.error('Upload failed:', error);
|
||||
};
|
||||
|
||||
@@ -63,6 +95,8 @@ export default function KBDoc({ kbId }: { kbId: string }) {
|
||||
<div className="container mx-auto py-2">
|
||||
<FileUploadZone
|
||||
kbId={kbId}
|
||||
ragEngineName={ragEngineName}
|
||||
ragEngineCapabilities={ragEngineCapabilities}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
onUploadError={handleUploadError}
|
||||
/>
|
||||
|
||||
@@ -14,18 +14,25 @@ import {
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@/components/ui/form';
|
||||
import { httpClient, systemInfo, userInfo } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { KnowledgeBase, EmbeddingModel } from '@/app/infra/entities/api';
|
||||
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import {
|
||||
DynamicFormItemConfig,
|
||||
getDefaultValues,
|
||||
parseDynamicFormItemType,
|
||||
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
import { UUID } from 'uuidjs';
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -34,15 +41,42 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
.string()
|
||||
.min(1, { message: t('knowledge.kbDescriptionRequired') }),
|
||||
emoji: z.string().optional(),
|
||||
embeddingModelUUID: z
|
||||
ragEngineId: z
|
||||
.string()
|
||||
.min(1, { message: t('knowledge.embeddingModelUUIDRequired') }),
|
||||
top_k: z
|
||||
.number()
|
||||
.min(1, { message: t('knowledge.topKRequired') })
|
||||
.max(30, { message: t('knowledge.topKMax') }),
|
||||
.min(1, { message: t('knowledge.knowledgeEngineRequired') }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
|
||||
* Same pattern as ExternalKBForm uses for retriever config
|
||||
*/
|
||||
function parseCreationSchema(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schemaItems: any | any[] | undefined,
|
||||
): IDynamicFormItemSchema[] {
|
||||
if (!schemaItems) return [];
|
||||
|
||||
// Handle wrapped schema (e.g. { schema: [...] }) which might be returned by the API
|
||||
const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema;
|
||||
|
||||
if (!items || !Array.isArray(items)) return [];
|
||||
|
||||
return items.map(
|
||||
(item) =>
|
||||
new DynamicFormItemConfig({
|
||||
default: item.default,
|
||||
id: UUID.generate(),
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
required: item.required,
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export default function KBForm({
|
||||
initKbId,
|
||||
onNewKbCreated,
|
||||
@@ -53,6 +87,17 @@ export default function KBForm({
|
||||
onKbUpdated: (kbId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]);
|
||||
const [selectedEngineId, setSelectedEngineId] = useState<string>('');
|
||||
const [configSettings, setConfigSettings] = useState<Record<string, unknown>>(
|
||||
{},
|
||||
);
|
||||
const [retrievalSettings, setRetrievalSettings] = useState<
|
||||
Record<string, unknown>
|
||||
>({});
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -61,70 +106,111 @@ export default function KBForm({
|
||||
name: '',
|
||||
description: t('knowledge.defaultDescription'),
|
||||
emoji: '📚',
|
||||
embeddingModelUUID: '',
|
||||
top_k: 5,
|
||||
ragEngineId: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||
// Get selected engine details
|
||||
const selectedEngine = ragEngines.find(
|
||||
(e) => e.plugin_id === selectedEngineId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getEmbeddingModelNameList().then(() => {
|
||||
loadRagEngines().then(() => {
|
||||
if (initKbId) {
|
||||
getKbConfig(initKbId).then((val) => {
|
||||
form.setValue('name', val.name);
|
||||
form.setValue('description', val.description);
|
||||
form.setValue('emoji', val.emoji);
|
||||
form.setValue('embeddingModelUUID', val.embeddingModelUUID);
|
||||
form.setValue('top_k', val.top_k || 5);
|
||||
});
|
||||
loadKbConfig(initKbId);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getKbConfig = async (
|
||||
kbId: string,
|
||||
): Promise<z.infer<typeof formSchema>> => {
|
||||
return new Promise((resolve) => {
|
||||
httpClient.getKnowledgeBase(kbId).then((res) => {
|
||||
resolve({
|
||||
name: res.base.name,
|
||||
description: res.base.description,
|
||||
emoji: res.base.emoji || '📚',
|
||||
embeddingModelUUID: res.base.embedding_model_uuid,
|
||||
top_k: res.base.top_k || 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
// Auto-select first engine when engines are loaded and no selection
|
||||
useEffect(() => {
|
||||
if (ragEngines.length > 0 && !selectedEngineId && !isEditing) {
|
||||
const firstEngine = ragEngines[0];
|
||||
setSelectedEngineId(firstEngine.plugin_id);
|
||||
form.setValue('ragEngineId', firstEngine.plugin_id);
|
||||
// Initialize config settings with defaults
|
||||
const formItems = parseCreationSchema(firstEngine.creation_schema);
|
||||
if (formItems.length > 0) {
|
||||
setConfigSettings(getDefaultValues(formItems));
|
||||
}
|
||||
const retrievalItems = parseCreationSchema(firstEngine.retrieval_schema);
|
||||
if (retrievalItems.length > 0) {
|
||||
setRetrievalSettings(getDefaultValues(retrievalItems));
|
||||
}
|
||||
}
|
||||
}, [ragEngines, selectedEngineId, isEditing]);
|
||||
|
||||
const loadRagEngines = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeEngines();
|
||||
setRagEngines(resp.engines);
|
||||
} catch (err) {
|
||||
console.error('Failed to load Knowledge Engines:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEmbeddingModelNameList = async () => {
|
||||
const resp = await httpClient.getProviderEmbeddingModels();
|
||||
let models = resp.models;
|
||||
// Filter out space-chat-completions models when not logged in with space account or when models service is disabled
|
||||
if (
|
||||
systemInfo.disable_models_service ||
|
||||
userInfo?.account_type !== 'space'
|
||||
) {
|
||||
models = models.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
const loadKbConfig = async (kbId: string) => {
|
||||
try {
|
||||
setIsEditing(true);
|
||||
|
||||
const res = await httpClient.getKnowledgeBase(kbId);
|
||||
const kb = res.base;
|
||||
|
||||
const engineId = kb.knowledge_engine_plugin_id || '';
|
||||
setSelectedEngineId(engineId);
|
||||
|
||||
form.setValue('name', kb.name);
|
||||
form.setValue('description', kb.description);
|
||||
form.setValue('emoji', kb.emoji || '📚');
|
||||
form.setValue('ragEngineId', engineId);
|
||||
|
||||
setConfigSettings(kb.creation_settings || {});
|
||||
setRetrievalSettings(kb.retrieval_settings || {});
|
||||
} catch (err) {
|
||||
console.error('Failed to load KB config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEngineChange = (engineId: string) => {
|
||||
setSelectedEngineId(engineId);
|
||||
form.setValue('ragEngineId', engineId);
|
||||
|
||||
// Find engine and initialize config settings with defaults from schema
|
||||
const engine = ragEngines.find((e) => e.plugin_id === engineId);
|
||||
if (engine) {
|
||||
const formItems = parseCreationSchema(engine.creation_schema);
|
||||
if (formItems.length > 0) {
|
||||
setConfigSettings(getDefaultValues(formItems));
|
||||
} else {
|
||||
setConfigSettings({});
|
||||
}
|
||||
const retrievalItems = parseCreationSchema(engine.retrieval_schema);
|
||||
if (retrievalItems.length > 0) {
|
||||
setRetrievalSettings(getDefaultValues(retrievalItems));
|
||||
} else {
|
||||
setRetrievalSettings({});
|
||||
}
|
||||
}
|
||||
setEmbeddingModels(models);
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||
const kbData: KnowledgeBase = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
emoji: data.emoji,
|
||||
knowledge_engine_plugin_id: selectedEngineId,
|
||||
creation_settings: configSettings,
|
||||
retrieval_settings: retrievalSettings,
|
||||
};
|
||||
|
||||
if (initKbId) {
|
||||
// update knowledge base
|
||||
const updateKb: KnowledgeBase = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
emoji: data.emoji,
|
||||
embedding_model_uuid: data.embeddingModelUUID,
|
||||
top_k: data.top_k,
|
||||
};
|
||||
// Update knowledge base
|
||||
httpClient
|
||||
.updateKnowledgeBase(initKbId, updateKb)
|
||||
.updateKnowledgeBase(initKbId, kbData)
|
||||
.then((res) => {
|
||||
onKbUpdated(res.uuid);
|
||||
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
||||
@@ -134,25 +220,50 @@ export default function KBForm({
|
||||
toast.error(t('knowledge.updateKnowledgeBaseFailed'));
|
||||
});
|
||||
} else {
|
||||
// create knowledge base
|
||||
const newKb: KnowledgeBase = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
emoji: data.emoji,
|
||||
embedding_model_uuid: data.embeddingModelUUID,
|
||||
top_k: data.top_k,
|
||||
};
|
||||
// Create knowledge base
|
||||
httpClient
|
||||
.createKnowledgeBase(newKb)
|
||||
.createKnowledgeBase(kbData)
|
||||
.then((res) => {
|
||||
onNewKbCreated(res.uuid);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('create knowledge base failed', err);
|
||||
toast.error(t('knowledge.createKnowledgeBaseFailed'));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Convert creation schema to dynamic form items (same as ExternalKBForm)
|
||||
const configFormItems = parseCreationSchema(selectedEngine?.creation_schema);
|
||||
|
||||
// Convert retrieval schema to dynamic form items
|
||||
const retrievalFormItems = parseCreationSchema(
|
||||
selectedEngine?.retrieval_schema,
|
||||
);
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show message if no engines available
|
||||
if (ragEngines.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
{t('knowledge.noEnginesAvailable')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.installEngineHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
@@ -162,6 +273,57 @@ export default function KBForm({
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Knowledge Engine Selector */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ragEngineId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.knowledgeEngine')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={isEditing}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleEngineChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue
|
||||
placeholder={t('knowledge.selectKnowledgeEngine')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
{ragEngines.map((engine) => (
|
||||
<SelectItem
|
||||
key={engine.plugin_id}
|
||||
value={engine.plugin_id}
|
||||
>
|
||||
{extractI18nObject(engine.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedEngine?.description && (
|
||||
<FormDescription>
|
||||
{extractI18nObject(selectedEngine.description)}
|
||||
</FormDescription>
|
||||
)}
|
||||
{isEditing && (
|
||||
<FormDescription>
|
||||
{t('knowledge.cannotChangeKnowledgeEngine')}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Name and Emoji in same row */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
@@ -197,6 +359,8 @@ export default function KBForm({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
@@ -213,96 +377,45 @@ export default function KBForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="embeddingModelUUID"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.embeddingModelUUID')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Select
|
||||
disabled={!!initKbId}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue
|
||||
placeholder={t('knowledge.selectEmbeddingModel')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
{(() => {
|
||||
const grouped = embeddingModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name ||
|
||||
model.provider?.requester ||
|
||||
'Unknown';
|
||||
if (!acc[providerName]) acc[providerName] = [];
|
||||
acc[providerName].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, EmbeddingModel[]>,
|
||||
);
|
||||
return Object.entries(grouped).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem
|
||||
key={model.uuid}
|
||||
value={model.uuid}
|
||||
>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
);
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{initKbId
|
||||
? t('knowledge.cannotChangeEmbeddingModel')
|
||||
: t('knowledge.embeddingModelDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="top_k"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.topK')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
className="w-[180px] h-10 text-base appearance-none"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('knowledge.topKdescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Engine specific fields (dynamic form from creation_schema) */}
|
||||
{configFormItems.length > 0 && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t('knowledge.engineSettings')}
|
||||
</div>
|
||||
<div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={configFormItems}
|
||||
initialValues={configSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setConfigSettings(val as Record<string, unknown>)
|
||||
}
|
||||
isEditing={isEditing}
|
||||
externalDependentValues={retrievalSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retrieval settings (dynamic form from retrieval_schema) */}
|
||||
{retrievalFormItems.length > 0 && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t('knowledge.retrievalSettings')}
|
||||
</div>
|
||||
<div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={retrievalFormItems}
|
||||
initialValues={retrievalSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setRetrievalSettings(val as Record<string, unknown>)
|
||||
}
|
||||
externalDependentValues={configSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { RetrieveResult } from '@/app/infra/entities/api';
|
||||
import KBRetrieveGeneric from './KBRetrieveGeneric';
|
||||
|
||||
interface ExternalKBRetrieveProps {
|
||||
kbId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* External knowledge base retrieve component
|
||||
* Uses the generic retrieve component with external KB API
|
||||
*/
|
||||
export default function ExternalKBRetrieve({ kbId }: ExternalKBRetrieveProps) {
|
||||
const getResultTitle = (result: RetrieveResult): string => {
|
||||
// For external KB, try to get document_name or use a generic title
|
||||
return (
|
||||
(result.metadata.document_name as string) ||
|
||||
(result.metadata.source as string) ||
|
||||
result.id
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KBRetrieveGeneric
|
||||
kbId={kbId}
|
||||
retrieveFunction={httpClient.retrieveExternalKnowledgeBase.bind(
|
||||
httpClient,
|
||||
)}
|
||||
getResultTitle={getResultTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { RetrieveResult, KnowledgeBaseFile } from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface KBRetrieveProps {
|
||||
kbId: string;
|
||||
}
|
||||
|
||||
export default function KBRetrieve({ kbId }: KBRetrieveProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<RetrieveResult[]>([]);
|
||||
const [files, setFiles] = useState<KnowledgeBaseFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const response = await httpClient.getKnowledgeBaseFiles(kbId);
|
||||
setFiles(response.files);
|
||||
} catch (error) {
|
||||
console.error('Failed to load files:', error);
|
||||
}
|
||||
};
|
||||
loadFiles();
|
||||
}, [kbId]);
|
||||
|
||||
const handleRetrieve = async () => {
|
||||
if (!query.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
setResults([]);
|
||||
const response = await httpClient.retrieveKnowledgeBase(kbId, query);
|
||||
setResults(response.results);
|
||||
} catch (error) {
|
||||
console.error('Retrieve failed:', error);
|
||||
toast.error(t('knowledge.retrieveError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (fileId?: string) => {
|
||||
if (!fileId) return '';
|
||||
const file = files.find((f) => f.uuid === fileId);
|
||||
return file?.file_name || fileId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract text content from the content array
|
||||
* The content array may contain multiple items with type 'text'
|
||||
*/
|
||||
const extractTextFromContent = (result: RetrieveResult): string => {
|
||||
// First try to get content from the new format
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
const textParts = result.content
|
||||
.filter((item) => item.type === 'text' && item.text)
|
||||
.map((item) => item.text);
|
||||
|
||||
if (textParts.length > 0) {
|
||||
return textParts.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to metadata.text for backward compatibility
|
||||
if (result.metadata?.text) {
|
||||
return result.metadata.text as string;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('knowledge.queryPlaceholder')}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleRetrieve()}
|
||||
/>
|
||||
<Button onClick={handleRetrieve} disabled={loading || !query.trim()}>
|
||||
{t('knowledge.query')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{results.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground">{t('knowledge.noResults')}</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
) : (
|
||||
results.map((result) => (
|
||||
<Card key={result.id} className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex justify-between items-center">
|
||||
<span>{getFileName(result.metadata.file_id)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('knowledge.distance')}: {result.distance.toFixed(4)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{extractTextFromContent(result)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -106,7 +106,8 @@ export default function KBRetrieveGeneric({
|
||||
<CardTitle className="text-sm font-medium flex justify-between items-center">
|
||||
<span>{getTitle(result)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('knowledge.distance')}: {result.distance.toFixed(4)}
|
||||
{t('knowledge.distance')}:{' '}
|
||||
{(result.distance ?? 0).toFixed(4)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -5,139 +5,64 @@ import styles from './knowledgeBase.module.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
|
||||
import { ExternalKBCardVO } from '@/app/home/knowledge/components/external-kb-card/ExternalKBCardVO';
|
||||
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
|
||||
import ExternalKBCard from '@/app/home/knowledge/components/external-kb-card/ExternalKBCard';
|
||||
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
KnowledgeBase,
|
||||
ExternalKnowledgeBase,
|
||||
ApiRespPluginSystemStatus,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('builtin');
|
||||
const [knowledgeBaseList, setKnowledgeBaseList] = useState<KnowledgeBaseVO[]>(
|
||||
[],
|
||||
);
|
||||
const [externalKBList, setExternalKBList] = useState<ExternalKBCardVO[]>([]);
|
||||
const [selectedKbId, setSelectedKbId] = useState<string>('');
|
||||
const [selectedKbType, setSelectedKbType] = useState<'builtin' | 'external'>(
|
||||
'builtin',
|
||||
);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getKnowledgeBaseList();
|
||||
getExternalKBList();
|
||||
fetchPluginSystemStatus();
|
||||
}, []);
|
||||
|
||||
async function fetchPluginSystemStatus() {
|
||||
try {
|
||||
const status = await httpClient.getPluginSystemStatus();
|
||||
setPluginSystemStatus(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugin system status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function getKnowledgeBaseList() {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
setKnowledgeBaseList(
|
||||
resp.bases.map((kb: KnowledgeBase) => {
|
||||
const currentTime = new Date();
|
||||
const lastUpdatedTimeAgo = Math.floor(
|
||||
(currentTime.getTime() -
|
||||
new Date(kb.updated_at ?? currentTime.getTime()).getTime()) /
|
||||
1000 /
|
||||
60 /
|
||||
60 /
|
||||
24,
|
||||
);
|
||||
|
||||
const lastUpdatedTimeAgoText =
|
||||
lastUpdatedTimeAgo > 0
|
||||
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
|
||||
: t('knowledge.today');
|
||||
const currentTime = new Date();
|
||||
|
||||
return new KnowledgeBaseVO({
|
||||
id: kb.uuid || '',
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji,
|
||||
embeddingModelUUID: kb.embedding_model_uuid,
|
||||
top_k: kb.top_k ?? 5,
|
||||
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getExternalKBList() {
|
||||
try {
|
||||
const resp = await httpClient.getExternalKnowledgeBases();
|
||||
setExternalKBList(
|
||||
resp.bases.map((kb: ExternalKnowledgeBase) => {
|
||||
const currentTime = new Date();
|
||||
const lastUpdatedTimeAgo = Math.floor(
|
||||
(currentTime.getTime() -
|
||||
new Date(kb.created_at ?? currentTime.getTime()).getTime()) /
|
||||
1000 /
|
||||
60 /
|
||||
60 /
|
||||
24,
|
||||
);
|
||||
|
||||
const lastUpdatedTimeAgoText =
|
||||
lastUpdatedTimeAgo > 0
|
||||
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
|
||||
: t('knowledge.today');
|
||||
|
||||
return new ExternalKBCardVO({
|
||||
id: kb.uuid || '',
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji,
|
||||
retrieverName: `${kb.plugin_author}/${kb.plugin_name}/${kb.retriever_name}`,
|
||||
retrieverConfig: kb.retriever_config || {},
|
||||
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
|
||||
pluginAuthor: kb.plugin_author,
|
||||
pluginName: kb.plugin_name,
|
||||
});
|
||||
}),
|
||||
const kbs = resp.bases.map((kb: KnowledgeBase) => {
|
||||
const lastUpdatedTimeAgo = Math.floor(
|
||||
(currentTime.getTime() -
|
||||
new Date(kb.updated_at ?? currentTime.getTime()).getTime()) /
|
||||
1000 /
|
||||
60 /
|
||||
60 /
|
||||
24,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to load external knowledge bases:', error);
|
||||
}
|
||||
|
||||
const lastUpdatedTimeAgoText =
|
||||
lastUpdatedTimeAgo > 0
|
||||
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
|
||||
: t('knowledge.today');
|
||||
|
||||
return new KnowledgeBaseVO({
|
||||
id: kb.uuid || '',
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji,
|
||||
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
|
||||
ragEngine: kb.knowledge_engine,
|
||||
ragEnginePluginId: kb.knowledge_engine_plugin_id,
|
||||
});
|
||||
});
|
||||
|
||||
setKnowledgeBaseList(kbs);
|
||||
}
|
||||
|
||||
const handleKBCardClick = (kbId: string) => {
|
||||
setSelectedKbId(kbId);
|
||||
setSelectedKbType('builtin');
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateKBClick = () => {
|
||||
setSelectedKbId('');
|
||||
setSelectedKbType('builtin');
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleExternalKBCardClick = (kbId: string) => {
|
||||
setSelectedKbId(kbId);
|
||||
setSelectedKbType('external');
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateExternalKB = () => {
|
||||
setSelectedKbId('');
|
||||
setSelectedKbType('external');
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -146,30 +71,18 @@ export default function KnowledgePage() {
|
||||
};
|
||||
|
||||
const handleKbDeleted = () => {
|
||||
if (selectedKbType === 'builtin') {
|
||||
getKnowledgeBaseList();
|
||||
} else {
|
||||
getExternalKBList();
|
||||
}
|
||||
getKnowledgeBaseList();
|
||||
setDetailDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleNewKbCreated = (newKbId: string) => {
|
||||
if (selectedKbType === 'builtin') {
|
||||
getKnowledgeBaseList();
|
||||
} else {
|
||||
getExternalKBList();
|
||||
}
|
||||
getKnowledgeBaseList();
|
||||
setSelectedKbId(newKbId);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleKbUpdated = () => {
|
||||
if (selectedKbType === 'builtin') {
|
||||
getKnowledgeBaseList();
|
||||
} else {
|
||||
getExternalKBList();
|
||||
}
|
||||
getKnowledgeBaseList();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -178,73 +91,28 @@ export default function KnowledgePage() {
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
kbId={selectedKbId || undefined}
|
||||
kbType={selectedKbType}
|
||||
onFormCancel={handleFormCancel}
|
||||
onKbDeleted={handleKbDeleted}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<div className="flex flex-row justify-between items-center px-[0.8rem]">
|
||||
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
||||
<TabsTrigger value="builtin" className="px-6 py-4 cursor-pointer">
|
||||
{t('knowledge.builtIn')}
|
||||
</TabsTrigger>
|
||||
{/* Only show external tab if plugin system is enabled and connected */}
|
||||
{pluginSystemStatus?.is_enable &&
|
||||
pluginSystemStatus?.is_connected && (
|
||||
<TabsTrigger
|
||||
value="external"
|
||||
className="px-6 py-4 cursor-pointer"
|
||||
>
|
||||
{t('knowledge.external')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className={styles.knowledgeListContainer}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={handleCreateKBClick}
|
||||
/>
|
||||
|
||||
<TabsContent value="builtin">
|
||||
<div className={styles.knowledgeListContainer}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={handleCreateKBClick}
|
||||
/>
|
||||
|
||||
{knowledgeBaseList.map((kb) => {
|
||||
return (
|
||||
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
|
||||
<KBCard kbCardVO={kb} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="external">
|
||||
<div className={styles.knowledgeListContainer}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={handleCreateExternalKB}
|
||||
/>
|
||||
|
||||
{externalKBList.map((kb) => {
|
||||
return (
|
||||
<div
|
||||
key={kb.id}
|
||||
onClick={() => handleExternalKBCardClick(kb.id)}
|
||||
>
|
||||
<ExternalKBCard kbCardVO={kb} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{knowledgeBaseList.map((kb) => {
|
||||
return (
|
||||
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
|
||||
<KBCard kbCardVO={kb} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TFunction } from 'i18next';
|
||||
import { Wrench, AudioWaveform, Hash, Book } from 'lucide-react';
|
||||
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function PluginComponentList({
|
||||
@@ -21,7 +21,8 @@ export default function PluginComponentList({
|
||||
Tool: <Wrench className="w-5 h-5" />,
|
||||
EventListener: <AudioWaveform className="w-5 h-5" />,
|
||||
Command: <Hash className="w-5 h-5" />,
|
||||
KnowledgeRetriever: <Book className="w-5 h-5" />,
|
||||
KnowledgeEngine: <Book className="w-5 h-5" />,
|
||||
Parser: <FileText className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const componentKindList = Object.keys(components || {});
|
||||
@@ -32,45 +33,39 @@ export default function PluginComponentList({
|
||||
{componentKindList.length > 0 && (
|
||||
<>
|
||||
{componentKindList.map((kind) => {
|
||||
return (
|
||||
<>
|
||||
{useBadge && (
|
||||
<Badge
|
||||
key={kind}
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{kindIconMap[kind]}
|
||||
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
|
||||
{responsive ? (
|
||||
<span className="hidden md:inline">
|
||||
{t('plugins.componentName.' + kind)}
|
||||
</span>
|
||||
) : (
|
||||
showComponentName && t('plugins.componentName.' + kind)
|
||||
)}
|
||||
<span className="ml-1">{components[kind]}</span>
|
||||
</Badge>
|
||||
return useBadge ? (
|
||||
<Badge
|
||||
key={kind}
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{kindIconMap[kind]}
|
||||
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
|
||||
{responsive ? (
|
||||
<span className="hidden md:inline">
|
||||
{t('plugins.componentName.' + kind)}
|
||||
</span>
|
||||
) : (
|
||||
showComponentName && t('plugins.componentName.' + kind)
|
||||
)}
|
||||
|
||||
{!useBadge && (
|
||||
<div
|
||||
key={kind}
|
||||
className="flex flex-row items-center justify-start gap-[0.2rem]"
|
||||
>
|
||||
{kindIconMap[kind]}
|
||||
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
|
||||
{responsive ? (
|
||||
<span className="hidden md:inline">
|
||||
{t('plugins.componentName.' + kind)}
|
||||
</span>
|
||||
) : (
|
||||
showComponentName && t('plugins.componentName.' + kind)
|
||||
)}
|
||||
<span className="ml-1">{components[kind]}</span>
|
||||
</div>
|
||||
<span className="ml-1">{components[kind]}</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<div
|
||||
key={kind}
|
||||
className="flex flex-row items-center justify-start gap-[0.2rem]"
|
||||
>
|
||||
{kindIconMap[kind]}
|
||||
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
|
||||
{responsive ? (
|
||||
<span className="hidden md:inline">
|
||||
{t('plugins.componentName.' + kind)}
|
||||
</span>
|
||||
) : (
|
||||
showComponentName && t('plugins.componentName.' + kind)
|
||||
)}
|
||||
</>
|
||||
<span className="ml-1">{components[kind]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -17,7 +17,14 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Search, Wrench, AudioWaveform, Hash, Book } from 'lucide-react';
|
||||
import {
|
||||
Search,
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
Hash,
|
||||
Book,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
@@ -499,12 +506,20 @@ function MarketPageContent({
|
||||
{t('plugins.componentName.EventListener')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="KnowledgeRetriever"
|
||||
aria-label="KnowledgeRetriever"
|
||||
value="KnowledgeEngine"
|
||||
aria-label="KnowledgeEngine"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Book className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.KnowledgeRetriever')}
|
||||
{t('plugins.componentName.KnowledgeEngine')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Parser"
|
||||
aria-label="Parser"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Parser')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
Book,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -41,7 +42,8 @@ export default function PluginMarketCardComponent({
|
||||
Tool: <Wrench className="w-4 h-4" />,
|
||||
EventListener: <AudioWaveform className="w-4 h-4" />,
|
||||
Command: <Hash className="w-4 h-4" />,
|
||||
KnowledgeRetriever: <Book className="w-4 h-4" />,
|
||||
KnowledgeEngine: <Book className="w-4 h-4" />,
|
||||
Parser: <FileText className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -70,17 +70,6 @@ export interface LLMModel {
|
||||
extra_args?: object;
|
||||
}
|
||||
|
||||
export interface KnowledgeBase {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
embedding_model_uuid: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
top_k: number;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
export interface ApiRespProviderEmbeddingModels {
|
||||
models: EmbeddingModel[];
|
||||
}
|
||||
@@ -166,31 +155,47 @@ export interface KnowledgeBase {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
embedding_model_uuid: string;
|
||||
top_k: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
emoji?: string;
|
||||
// New unified fields
|
||||
knowledge_engine_plugin_id?: string;
|
||||
creation_settings?: Record<string, unknown>;
|
||||
retrieval_settings?: Record<string, unknown>;
|
||||
knowledge_engine?: KnowledgeEngineInfo;
|
||||
}
|
||||
|
||||
export interface ExternalKnowledgeBase {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at?: string;
|
||||
plugin_author: string;
|
||||
plugin_name: string;
|
||||
retriever_name: string;
|
||||
retriever_config?: Record<string, unknown>;
|
||||
emoji?: string;
|
||||
// Knowledge Engine types
|
||||
export interface KnowledgeEngineInfo {
|
||||
plugin_id: string | null;
|
||||
name: I18nObject;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export interface ApiRespExternalKnowledgeBases {
|
||||
bases: ExternalKnowledgeBase[];
|
||||
export interface KnowledgeEngine {
|
||||
plugin_id: string;
|
||||
name: I18nObject;
|
||||
description?: I18nObject;
|
||||
capabilities: string[];
|
||||
// Schema format: Array of form field definitions (IDynamicFormItemSchema-like)
|
||||
// Each item: { name, label, type, required, default, description?, options? }
|
||||
creation_schema?: unknown[];
|
||||
retrieval_schema?: unknown[];
|
||||
}
|
||||
|
||||
export interface ApiRespExternalKnowledgeBase {
|
||||
base: ExternalKnowledgeBase;
|
||||
export interface ApiRespKnowledgeEngines {
|
||||
engines: KnowledgeEngine[];
|
||||
}
|
||||
|
||||
export interface ParserInfo {
|
||||
plugin_id: string;
|
||||
name: I18nObject;
|
||||
description?: I18nObject;
|
||||
supported_mime_types: string[];
|
||||
}
|
||||
|
||||
export interface ApiRespParsers {
|
||||
parsers: ParserInfo[];
|
||||
}
|
||||
|
||||
export interface ApiRespKnowledgeBaseFiles {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
export interface IShowIfCondition {
|
||||
field: string;
|
||||
operator: 'eq' | 'neq' | 'in';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface IDynamicFormItemSchema {
|
||||
id: string;
|
||||
default: string | number | boolean | Array<unknown>;
|
||||
@@ -9,6 +16,7 @@ export interface IDynamicFormItemSchema {
|
||||
type: DynamicFormItemType;
|
||||
description?: I18nObject;
|
||||
options?: IDynamicFormItemOption[];
|
||||
show_if?: IShowIfCondition;
|
||||
|
||||
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||
scopes?: string[];
|
||||
@@ -26,6 +34,7 @@ export enum DynamicFormItemType {
|
||||
FILE_ARRAY = 'array[file]',
|
||||
SELECT = 'select',
|
||||
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
||||
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
|
||||
PROMPT_EDITOR = 'prompt-editor',
|
||||
UNKNOWN = 'unknown',
|
||||
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
|
||||
|
||||
@@ -35,12 +35,11 @@ import {
|
||||
ApiRespMCPServers,
|
||||
ApiRespMCPServer,
|
||||
MCPServer,
|
||||
ExternalKnowledgeBase,
|
||||
ApiRespExternalKnowledgeBases,
|
||||
ApiRespExternalKnowledgeBase,
|
||||
ApiRespModelProviders,
|
||||
ApiRespModelProvider,
|
||||
ModelProvider,
|
||||
ApiRespKnowledgeEngines,
|
||||
ApiRespParsers,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
@@ -435,9 +434,11 @@ export class BackendClient extends BaseHttpClient {
|
||||
public uploadKnowledgeBaseFile(
|
||||
uuid: string,
|
||||
file_id: string,
|
||||
parserPluginId?: string,
|
||||
): Promise<object> {
|
||||
return this.post(`/api/v1/knowledge/bases/${uuid}/files`, {
|
||||
file_id,
|
||||
parser_plugin_id: parserPluginId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -461,49 +462,23 @@ export class BackendClient extends BaseHttpClient {
|
||||
public retrieveKnowledgeBase(
|
||||
uuid: string,
|
||||
query: string,
|
||||
retrievalSettings?: Record<string, unknown>,
|
||||
): Promise<ApiRespKnowledgeBaseRetrieve> {
|
||||
return this.post(`/api/v1/knowledge/bases/${uuid}/retrieve`, { query });
|
||||
}
|
||||
|
||||
// ============ External Knowledge Base API ============
|
||||
public getExternalKnowledgeBases(): Promise<ApiRespExternalKnowledgeBases> {
|
||||
return this.get('/api/v1/knowledge/external-bases');
|
||||
}
|
||||
|
||||
public getExternalKnowledgeBase(
|
||||
uuid: string,
|
||||
): Promise<ApiRespExternalKnowledgeBase> {
|
||||
return this.get(`/api/v1/knowledge/external-bases/${uuid}`);
|
||||
}
|
||||
|
||||
public createExternalKnowledgeBase(
|
||||
base: ExternalKnowledgeBase,
|
||||
): Promise<{ uuid: string }> {
|
||||
return this.post('/api/v1/knowledge/external-bases', base);
|
||||
}
|
||||
|
||||
public updateExternalKnowledgeBase(
|
||||
uuid: string,
|
||||
base: ExternalKnowledgeBase,
|
||||
): Promise<{ uuid: string }> {
|
||||
return this.put(`/api/v1/knowledge/external-bases/${uuid}`, base);
|
||||
}
|
||||
|
||||
public deleteExternalKnowledgeBase(uuid: string): Promise<object> {
|
||||
return this.delete(`/api/v1/knowledge/external-bases/${uuid}`);
|
||||
}
|
||||
|
||||
public retrieveExternalKnowledgeBase(
|
||||
uuid: string,
|
||||
query: string,
|
||||
): Promise<ApiRespKnowledgeBaseRetrieve> {
|
||||
return this.post(`/api/v1/knowledge/external-bases/${uuid}/retrieve`, {
|
||||
return this.post(`/api/v1/knowledge/bases/${uuid}/retrieve`, {
|
||||
query,
|
||||
retrieval_settings: retrievalSettings ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
public listKnowledgeRetrievers(): Promise<{ retrievers: unknown[] }> {
|
||||
return this.get('/api/v1/knowledge/external-bases/retrievers');
|
||||
// ============ Knowledge Engines API ============
|
||||
public getKnowledgeEngines(): Promise<ApiRespKnowledgeEngines> {
|
||||
return this.get('/api/v1/knowledge/engines');
|
||||
}
|
||||
|
||||
// ============ Parsers API ============
|
||||
public listParsers(mimeType?: string): Promise<ApiRespParsers> {
|
||||
const params = mimeType ? `?mime_type=${encodeURIComponent(mimeType)}` : '';
|
||||
return this.get(`/api/v1/knowledge/parsers${params}`);
|
||||
}
|
||||
|
||||
// ============ Plugins API ============
|
||||
|
||||
@@ -48,6 +48,7 @@ const enUS = {
|
||||
test: 'Test',
|
||||
forgotPassword: 'Forgot Password?',
|
||||
loading: 'Loading...',
|
||||
fieldRequired: 'This field is required',
|
||||
or: 'or',
|
||||
loginWithSpace: 'Login with Space',
|
||||
spaceLoginRecommended:
|
||||
@@ -371,7 +372,8 @@ const enUS = {
|
||||
Tool: 'Tool',
|
||||
EventListener: 'Event Listener',
|
||||
Command: 'Command',
|
||||
KnowledgeRetriever: 'Knowledge Retriever',
|
||||
KnowledgeEngine: 'Knowledge Engine',
|
||||
Parser: 'Parser',
|
||||
},
|
||||
uploadLocal: 'Upload Local',
|
||||
debugging: 'Debugging',
|
||||
@@ -726,6 +728,12 @@ const enUS = {
|
||||
processing: 'Processing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
selectParser: 'Select Parser',
|
||||
builtInParser: 'Provided by Knowledge engine',
|
||||
noParserAvailable:
|
||||
'No parser supports this file type. Please install a parser plugin that can handle this format.',
|
||||
confirmUpload: 'Upload',
|
||||
cancelUpload: 'Cancel',
|
||||
},
|
||||
deleteKnowledgeBaseConfirmation:
|
||||
'Are you sure you want to delete this knowledge base? All documents in this knowledge base will be deleted.',
|
||||
@@ -738,8 +746,24 @@ const enUS = {
|
||||
fileName: 'File Name',
|
||||
noResults: 'No results',
|
||||
retrieveError: 'Retrieve failed',
|
||||
builtIn: 'Built-in',
|
||||
external: 'External',
|
||||
unknownEngine: 'Unknown Engine',
|
||||
knowledgeEngine: 'Knowledge Engine',
|
||||
knowledgeEngineRequired: 'Knowledge engine is required',
|
||||
selectKnowledgeEngine: 'Select Knowledge Engine',
|
||||
builtInEngine: 'Built-in Engine',
|
||||
cannotChangeKnowledgeEngine:
|
||||
'Knowledge engine cannot be changed after creation',
|
||||
engineSettings: 'Engine Settings',
|
||||
engineSettingsReadonly: 'read-only in edit mode',
|
||||
retrievalSettings: 'Retrieval Settings',
|
||||
noEnginesAvailable: 'No knowledge base engines available',
|
||||
installEngineHint: 'Please install a knowledge base plugin first',
|
||||
createKnowledgeBaseFailed: 'Failed to create knowledge base',
|
||||
loadKnowledgeBaseFailed: 'Failed to load knowledge base',
|
||||
deleteKnowledgeBaseFailed: 'Failed to delete knowledge base',
|
||||
getKnowledgeBaseListError: 'Failed to get knowledge base list: ',
|
||||
embeddingModel: 'Embedding Model',
|
||||
embeddingModelRequired: 'Embedding model is required for this engine',
|
||||
addExternal: 'Add External Knowledge Base',
|
||||
createExternalSuccess: 'External knowledge base created successfully',
|
||||
updateExternalSuccess: 'External knowledge base updated successfully',
|
||||
|
||||
@@ -49,6 +49,7 @@ const jaJP = {
|
||||
test: 'テスト',
|
||||
forgotPassword: 'パスワードを忘れた?',
|
||||
loading: '読み込み中...',
|
||||
fieldRequired: 'この項目は必須です',
|
||||
or: 'または',
|
||||
loginWithSpace: 'Space でログイン',
|
||||
spaceLoginRecommended:
|
||||
@@ -371,7 +372,8 @@ const jaJP = {
|
||||
Tool: 'ツール',
|
||||
EventListener: 'イベント監視器',
|
||||
Command: 'コマンド',
|
||||
KnowledgeRetriever: '知識検索',
|
||||
KnowledgeEngine: '知識エンジン',
|
||||
Parser: 'パーサー',
|
||||
},
|
||||
uploadLocal: 'ローカルアップロード',
|
||||
debugging: 'デバッグ中',
|
||||
@@ -729,6 +731,12 @@ const jaJP = {
|
||||
processing: '処理中',
|
||||
completed: '完了',
|
||||
failed: '失敗',
|
||||
selectParser: 'パーサーを選択',
|
||||
builtInParser: '知識エンジンが提供',
|
||||
noParserAvailable:
|
||||
'このファイル形式に対応するパーサーがありません。対応するパーサープラグインをインストールしてください。',
|
||||
confirmUpload: 'アップロード',
|
||||
cancelUpload: 'キャンセル',
|
||||
},
|
||||
deleteKnowledgeBaseConfirmation:
|
||||
'本当にこの知識ベースを削除しますか?この知識ベースに紐付けられたドキュメントは削除されます。',
|
||||
@@ -741,8 +749,10 @@ const jaJP = {
|
||||
fileName: 'ファイル名',
|
||||
noResults: '検索結果がありません',
|
||||
retrieveError: '検索に失敗しました',
|
||||
builtIn: '内蔵',
|
||||
external: '外部ナレッジベース',
|
||||
unknownEngine: '不明なエンジン',
|
||||
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました',
|
||||
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました',
|
||||
getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:',
|
||||
addExternal: '外部ナレッジベースを追加',
|
||||
createExternalSuccess: '外部ナレッジベースが正常に作成されました',
|
||||
updateExternalSuccess: '外部ナレッジベースが正常に更新されました',
|
||||
|
||||
@@ -48,6 +48,7 @@ const zhHans = {
|
||||
test: '测试',
|
||||
forgotPassword: '忘记密码?',
|
||||
loading: '加载中...',
|
||||
fieldRequired: '此字段为必填项',
|
||||
or: '或',
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
||||
@@ -353,7 +354,8 @@ const zhHans = {
|
||||
Tool: '工具',
|
||||
EventListener: '事件监听器',
|
||||
Command: '命令',
|
||||
KnowledgeRetriever: '知识检索',
|
||||
KnowledgeEngine: '知识引擎',
|
||||
Parser: '解析器',
|
||||
},
|
||||
uploadLocal: '本地上传',
|
||||
debugging: '调试中',
|
||||
@@ -696,6 +698,12 @@ const zhHans = {
|
||||
processing: '处理中',
|
||||
completed: '完成',
|
||||
failed: '失败',
|
||||
selectParser: '选择解析器',
|
||||
builtInParser: '由知识引擎提供',
|
||||
noParserAvailable:
|
||||
'没有解析器支持此文件类型,请安装支持该格式的解析器插件。',
|
||||
confirmUpload: '上传',
|
||||
cancelUpload: '取消',
|
||||
},
|
||||
deleteKnowledgeBaseConfirmation:
|
||||
'你确定要删除这个知识库吗?此知识库下的所有文档将被删除。',
|
||||
@@ -708,8 +716,23 @@ const zhHans = {
|
||||
fileName: '文件名',
|
||||
noResults: '暂无结果',
|
||||
retrieveError: '检索失败',
|
||||
builtIn: '内置',
|
||||
external: '外部知识库',
|
||||
unknownEngine: '未知引擎',
|
||||
knowledgeEngine: '知识引擎',
|
||||
knowledgeEngineRequired: '知识引擎不能为空',
|
||||
selectKnowledgeEngine: '选择知识引擎',
|
||||
builtInEngine: '内置引擎',
|
||||
cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎',
|
||||
engineSettings: '引擎设置',
|
||||
engineSettingsReadonly: '编辑模式下不可修改',
|
||||
retrievalSettings: '检索设置',
|
||||
noEnginesAvailable: '没有可用的知识库引擎',
|
||||
installEngineHint: '请先安装知识库插件',
|
||||
createKnowledgeBaseFailed: '知识库创建失败',
|
||||
loadKnowledgeBaseFailed: '知识库加载失败',
|
||||
deleteKnowledgeBaseFailed: '知识库删除失败',
|
||||
getKnowledgeBaseListError: '获取知识库列表失败:',
|
||||
embeddingModel: '嵌入模型',
|
||||
embeddingModelRequired: '此引擎需要选择嵌入模型',
|
||||
addExternal: '添加外部知识库',
|
||||
createExternalSuccess: '外部知识库创建成功',
|
||||
updateExternalSuccess: '外部知识库更新成功',
|
||||
|
||||
@@ -48,6 +48,7 @@ const zhHant = {
|
||||
test: '測試',
|
||||
forgotPassword: '忘記密碼?',
|
||||
loading: '載入中...',
|
||||
fieldRequired: '此欄位為必填',
|
||||
or: '或',
|
||||
loginWithSpace: '透過 Space 登入',
|
||||
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
||||
@@ -347,7 +348,8 @@ const zhHant = {
|
||||
Tool: '工具',
|
||||
EventListener: '事件監聽器',
|
||||
Command: '命令',
|
||||
KnowledgeRetriever: '知識檢索',
|
||||
KnowledgeEngine: '知識引擎',
|
||||
Parser: '解析器',
|
||||
},
|
||||
uploadLocal: '本地上傳',
|
||||
debugging: '調試中',
|
||||
@@ -689,6 +691,12 @@ const zhHant = {
|
||||
processing: '處理中',
|
||||
completed: '完成',
|
||||
failed: '失敗',
|
||||
selectParser: '選擇解析器',
|
||||
builtInParser: '由知識引擎提供',
|
||||
noParserAvailable:
|
||||
'沒有解析器支援此檔案類型,請安裝支援該格式的解析器插件。',
|
||||
confirmUpload: '上傳',
|
||||
cancelUpload: '取消',
|
||||
},
|
||||
deleteKnowledgeBaseConfirmation:
|
||||
'您確定要刪除這個知識庫嗎?此知識庫下的所有文檔將被刪除。',
|
||||
@@ -701,8 +709,10 @@ const zhHant = {
|
||||
fileName: '文檔名稱',
|
||||
noResults: '暫無結果',
|
||||
retrieveError: '檢索失敗',
|
||||
builtIn: '內置',
|
||||
external: '外部知識庫',
|
||||
unknownEngine: '未知引擎',
|
||||
loadKnowledgeBaseFailed: '知識庫載入失敗',
|
||||
deleteKnowledgeBaseFailed: '知識庫刪除失敗',
|
||||
getKnowledgeBaseListError: '取得知識庫列表失敗:',
|
||||
addExternal: '添加外部知識庫',
|
||||
createExternalSuccess: '外部知識庫創建成功',
|
||||
updateExternalSuccess: '外部知識庫更新成功',
|
||||
|
||||
Reference in New Issue
Block a user