From 08c5f82a04685d2ef74d29ab48dd0e15aac08fad Mon Sep 17 00:00:00 2001 From: one Date: Sat, 26 Jul 2025 10:54:06 +0800 Subject: [PATCH] refactor(Knowledge): simplify dimension settings, support base migration (#8315) * refactor(knowledge): simplify dimension settings, support base migration Embedding dimension - remove 'auto set dimension', let the user take control - reuse findModelById - improve error messages for VoyageEmbeddings Knowledgebase migration - enable migration when model or dimension changes - add knowledgeThunk to reuse code KnowledgeSettings - unify UI for AddKnowledgeBasePopup and EditKnowledgeBasePopup - refactor KnowledgeSettings, split it to smaller components Tests: - knowledgeThunk - InputEmbeddingDimension - KnowledgeBaseFormModal - GeneralSettingsPanel - AdvancedSettingsPanel - InfoTooltip Misc. - add InfoTooltip - remove MemoriesSettingsModal * fix: i18n and vitest config --- docs/technical/how-to-i18n-en.md | 4 +- docs/technical/how-to-i18n-zh.md | 4 +- package.json | 1 + .../knowledge/embeddings/EmbeddingsFactory.ts | 3 +- .../knowledge/embeddings/VoyageEmbeddings.ts | 22 +- src/main/knowledge/embeddings/utils.ts | 45 -- src/renderer/src/components/InfoTooltip.tsx | 19 + .../components/InputEmbeddingDimension.tsx | 90 +++ .../src/components/ListItem/index.tsx | 11 +- .../components/__tests__/InfoTooltip.test.tsx | 24 + .../InputEmbeddingDimension.test.tsx | 197 +++++++ .../__snapshots__/InfoTooltip.test.tsx.snap | 28 + .../InputEmbeddingDimension.test.tsx.snap | 221 ++++++++ src/renderer/src/context/AntdProvider.tsx | 1 + src/renderer/src/hooks/useKnowledge.ts | 169 +++--- .../src/hooks/useKnowledgeBaseForm.ts | 161 ++++++ src/renderer/src/i18n/locales/en-us.json | 37 +- src/renderer/src/i18n/locales/ja-jp.json | 37 +- src/renderer/src/i18n/locales/ru-ru.json | 37 +- src/renderer/src/i18n/locales/zh-cn.json | 37 +- src/renderer/src/i18n/locales/zh-tw.json | 37 +- src/renderer/src/i18n/translate/el-gr.json | 32 +- src/renderer/src/i18n/translate/es-es.json | 32 +- src/renderer/src/i18n/translate/fr-fr.json | 32 +- src/renderer/src/i18n/translate/pt-pt.json | 32 +- .../src/pages/knowledge/KnowledgeContent.tsx | 4 +- .../src/pages/knowledge/KnowledgePage.tsx | 21 +- .../__tests__/AdvancedSettingsPanel.test.tsx | 101 ++++ .../__tests__/GeneralSettingsPanel.test.tsx | 301 ++++++++++ .../__tests__/KnowledgeBaseFormModal.test.tsx | 235 ++++++++ .../AdvancedSettingsPanel.test.tsx.snap | 329 +++++++++++ .../GeneralSettingsPanel.test.tsx.snap | 201 +++++++ .../KnowledgeBaseFormModal.test.tsx.snap | 141 +++++ .../components/AddKnowledgeBasePopup.tsx | 117 ++++ .../components/AddKnowledgePopup.tsx | 533 ------------------ .../components/EditKnowledgeBasePopup.tsx | 161 ++++++ .../components/KnowledgeSettings.tsx | 423 -------------- .../AdvancedSettingsPanel.tsx | 76 +++ .../GeneralSettingsPanel.tsx | 128 +++++ .../KnowledgeBaseFormModal.tsx | 115 ++++ .../components/KnowledgeSettings/index.ts | 4 + .../components/KnowledgeSettings/styles.ts | 17 + .../src/pages/memory/settings-modal.tsx | 119 ++-- .../AssistantMemorySettings.tsx | 3 +- .../MemorySettings/MemoriesSettingsModal.tsx | 214 ------- .../MemorySettings/MemorySettings.tsx | 2 +- .../CompressionSettings/RagSettings.tsx | 68 +-- .../thunk/__tests__/knowledgeThunk.test.ts | 241 ++++++++ .../src/store/thunk/knowledgeThunk.ts | 73 +++ vitest.config.ts | 2 +- yarn.lock | 1 + 51 files changed, 3385 insertions(+), 1558 deletions(-) delete mode 100644 src/main/knowledge/embeddings/utils.ts create mode 100644 src/renderer/src/components/InfoTooltip.tsx create mode 100644 src/renderer/src/components/InputEmbeddingDimension.tsx create mode 100644 src/renderer/src/components/__tests__/InfoTooltip.test.tsx create mode 100644 src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx create mode 100644 src/renderer/src/components/__tests__/__snapshots__/InfoTooltip.test.tsx.snap create mode 100644 src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap create mode 100644 src/renderer/src/hooks/useKnowledgeBaseForm.ts create mode 100644 src/renderer/src/pages/knowledge/__tests__/AdvancedSettingsPanel.test.tsx create mode 100644 src/renderer/src/pages/knowledge/__tests__/GeneralSettingsPanel.test.tsx create mode 100644 src/renderer/src/pages/knowledge/__tests__/KnowledgeBaseFormModal.test.tsx create mode 100644 src/renderer/src/pages/knowledge/__tests__/__snapshots__/AdvancedSettingsPanel.test.tsx.snap create mode 100644 src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap create mode 100644 src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap create mode 100644 src/renderer/src/pages/knowledge/components/AddKnowledgeBasePopup.tsx delete mode 100644 src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx create mode 100644 src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx delete mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettings.tsx create mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettings/AdvancedSettingsPanel.tsx create mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx create mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx create mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettings/index.ts create mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettings/styles.ts delete mode 100644 src/renderer/src/pages/settings/MemorySettings/MemoriesSettingsModal.tsx create mode 100644 src/renderer/src/store/thunk/__tests__/knowledgeThunk.test.ts create mode 100644 src/renderer/src/store/thunk/knowledgeThunk.ts diff --git a/docs/technical/how-to-i18n-en.md b/docs/technical/how-to-i18n-en.md index afb138de36..861810dc6b 100644 --- a/docs/technical/how-to-i18n-en.md +++ b/docs/technical/how-to-i18n-en.md @@ -64,13 +64,14 @@ Never use flat structures like `"add.button.tip": "Add"`. Instead, adopt a clear #### 1. **Plugin Cannot Track Dynamic Keys** Tools like i18n Ally cannot parse dynamic content within template strings, resulting in: + - No real-time preview - No detection of missing translations - No navigation to key definitions ```javascript // Not recommended - Plugin cannot resolve -const message = t(`fruits.${fruit}`); +const message = t(`fruits.${fruit}`) ``` #### 2. **No Real-time Rendering in Editor** @@ -103,6 +104,7 @@ The project includes several scripts to automate i18n-related tasks: ### `check:i18n` - Validate i18n Structure This script checks: + - Whether all language files use nested structure - For missing or unused keys - Whether keys are properly sorted diff --git a/docs/technical/how-to-i18n-zh.md b/docs/technical/how-to-i18n-zh.md index 5485aefadb..e4fd9c637d 100644 --- a/docs/technical/how-to-i18n-zh.md +++ b/docs/technical/how-to-i18n-zh.md @@ -60,13 +60,14 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 1. **插件无法跟踪** i18n ally等工具无法解析模板字符串中的动态内容,导致: + - 无法正确显示实时预览 - 无法检测翻译缺失 - 无法提供跳转到定义的功能 ```javascript // 不推荐 - 插件无法解析 - const message = t(`fruits.${fruit}`); + const message = t(`fruits.${fruit}`) ``` 2. **编辑器无法实时渲染** @@ -97,6 +98,7 @@ const label = fruitLabels[fruit] ### `check:i18n` - 检查i18n结构 此脚本会检查: + - 所有语言文件是否为嵌套结构 - 是否存在缺失的key - 是否存在多余的key diff --git a/package.json b/package.json index a9cdf2a126..26c98f6b94 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@tryfabric/martian": "^1.2.4", "@types/cli-progress": "^3", "@types/diff": "^7", diff --git a/src/main/knowledge/embeddings/EmbeddingsFactory.ts b/src/main/knowledge/embeddings/EmbeddingsFactory.ts index 6b54f6d904..7435ad2bb0 100644 --- a/src/main/knowledge/embeddings/EmbeddingsFactory.ts +++ b/src/main/knowledge/embeddings/EmbeddingsFactory.ts @@ -4,7 +4,6 @@ import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai' import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings' import { ApiClient } from '@types' -import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils' import { VoyageEmbeddings } from './VoyageEmbeddings' export default class EmbeddingsFactory { @@ -15,7 +14,7 @@ export default class EmbeddingsFactory { return new VoyageEmbeddings({ modelName: model, apiKey, - outputDimension: VOYAGE_SUPPORTED_DIM_MODELS.includes(model) ? dimensions : undefined, + outputDimension: dimensions, batchSize: 8 }) } diff --git a/src/main/knowledge/embeddings/VoyageEmbeddings.ts b/src/main/knowledge/embeddings/VoyageEmbeddings.ts index a8260a4ae9..1f65f48730 100644 --- a/src/main/knowledge/embeddings/VoyageEmbeddings.ts +++ b/src/main/knowledge/embeddings/VoyageEmbeddings.ts @@ -1,10 +1,5 @@ import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces' import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage' -import { loggerService } from '@logger' - -import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils' - -const logger = loggerService.withContext('VoyageEmbeddings') /** * 支持设置嵌入维度的模型 @@ -14,23 +9,24 @@ export class VoyageEmbeddings extends BaseEmbeddings { constructor(private readonly configuration?: ConstructorParameters[0]) { super() if (!this.configuration) { - throw new Error('Pass in a configuration.') + throw new Error('Invalid configuration') } if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3' - if (!VOYAGE_SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) { - logger.error(`VoyageEmbeddings only supports ${VOYAGE_SUPPORTED_DIM_MODELS.join(', ')} to set outputDimension.`) - this.model = new _VoyageEmbeddings({ ...this.configuration, outputDimension: undefined }) - } else { - this.model = new _VoyageEmbeddings(this.configuration) - } + this.model = new _VoyageEmbeddings(this.configuration) } override async getDimensions(): Promise { return this.configuration?.outputDimension ?? (this.configuration?.modelName === 'voyage-code-2' ? 1536 : 1024) } override async embedDocuments(texts: string[]): Promise { - return this.model.embedDocuments(texts) + try { + return this.model.embedDocuments(texts) + } catch (error) { + throw new Error('Embedding documents failed - you may have hit the rate limit or there is an internal error', { + cause: error + }) + } } override async embedQuery(text: string): Promise { diff --git a/src/main/knowledge/embeddings/utils.ts b/src/main/knowledge/embeddings/utils.ts deleted file mode 100644 index 9b6bd54935..0000000000 --- a/src/main/knowledge/embeddings/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const VOYAGE_SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3'] - -// NOTE: 下面的暂时没用上,但先留着吧 -export const OPENAI_SUPPORTED_DIM_MODELS = ['text-embedding-3-small', 'text-embedding-3-large'] - -export const DASHSCOPE_SUPPORTED_DIM_MODELS = ['text-embedding-v3', 'text-embedding-v4'] - -export const OPENSOURCE_SUPPORTED_DIM_MODELS = ['qwen3-embedding-0.6B', 'qwen3-embedding-4B', 'qwen3-embedding-8B'] - -export const GOOGLE_SUPPORTED_DIM_MODELS = ['gemini-embedding-exp-03-07', 'gemini-embedding-exp'] - -export const SUPPORTED_DIM_MODELS = [ - ...VOYAGE_SUPPORTED_DIM_MODELS, - ...OPENAI_SUPPORTED_DIM_MODELS, - ...DASHSCOPE_SUPPORTED_DIM_MODELS, - ...OPENSOURCE_SUPPORTED_DIM_MODELS, - ...GOOGLE_SUPPORTED_DIM_MODELS -] - -/** - * 从模型 ID 中提取基础名称。 - * 例如: - * - 'deepseek/deepseek-r1' => 'deepseek-r1' - * - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1' - * @param {string} id 模型 ID - * @param {string} [delimiter='/'] 分隔符,默认为 '/' - * @returns {string} 基础名称 - */ -export const getBaseModelName = (id: string, delimiter: string = '/'): string => { - const parts = id.split(delimiter) - return parts[parts.length - 1] -} - -/** - * 从模型 ID 中提取基础名称并转换为小写。 - * 例如: - * - 'deepseek/DeepSeek-R1' => 'deepseek-r1' - * - 'deepseek-ai/deepseek/DeepSeek-R1' => 'deepseek-r1' - * @param {string} id 模型 ID - * @param {string} [delimiter='/'] 分隔符,默认为 '/' - * @returns {string} 小写的基础名称 - */ -export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => { - return getBaseModelName(id, delimiter).toLowerCase() -} diff --git a/src/renderer/src/components/InfoTooltip.tsx b/src/renderer/src/components/InfoTooltip.tsx new file mode 100644 index 0000000000..e1d850a8b8 --- /dev/null +++ b/src/renderer/src/components/InfoTooltip.tsx @@ -0,0 +1,19 @@ +import { InfoCircleOutlined } from '@ant-design/icons' +import { Tooltip, TooltipProps } from 'antd' + +type InheritedTooltipProps = Omit + +interface InfoTooltipProps extends InheritedTooltipProps { + iconColor?: string + iconStyle?: React.CSSProperties +} + +const InfoTooltip = ({ iconColor = 'var(--color-text-3)', iconStyle, ...rest }: InfoTooltipProps) => { + return ( + + + + ) +} + +export default InfoTooltip diff --git a/src/renderer/src/components/InputEmbeddingDimension.tsx b/src/renderer/src/components/InputEmbeddingDimension.tsx new file mode 100644 index 0000000000..f70c0222bd --- /dev/null +++ b/src/renderer/src/components/InputEmbeddingDimension.tsx @@ -0,0 +1,90 @@ +import { loggerService } from '@logger' +import AiProvider from '@renderer/aiCore' +import { useProvider } from '@renderer/hooks/useProvider' +import { Model } from '@renderer/types' +import { getErrorMessage } from '@renderer/utils' +import { Button, InputNumber, Space, Tooltip } from 'antd' +import { RefreshCw } from 'lucide-react' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const logger = loggerService.withContext('DimensionsInput') + +interface InputEmbeddingDimensionProps { + value?: number | null + onChange?: (value: number | null) => void + model?: Model + disabled?: boolean + style?: React.CSSProperties +} + +const InputEmbeddingDimension = ({ + ref, + value, + onChange, + model, + disabled: _disabled, + style +}: InputEmbeddingDimensionProps & { ref?: React.RefObject | null }) => { + const { t } = useTranslation() + const { provider } = useProvider(model?.provider ?? '') + const [loading, setLoading] = useState(false) + + const disabled = useMemo(() => _disabled || !model || !provider, [_disabled, model, provider]) + + const handleFetchDimension = useCallback(async () => { + if (!model) { + logger.warn('Failed to get embedding dimensions: no model') + window.message.error(t('knowledge.embedding_model_required')) + return + } + + if (!provider) { + logger.warn('Failed to get embedding dimensions: no provider') + window.message.error(t('knowledge.provider_not_found')) + return + } + + setLoading(true) + try { + const aiProvider = new AiProvider(provider) + const dimension = await aiProvider.getEmbeddingDimensions(model) + // for controlled input + if (ref?.current) { + ref.current.value = dimension.toString() + } + onChange?.(dimension) + } catch (error) { + logger.error(t('message.error.get_embedding_dimensions'), error as Error) + window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error)) + } finally { + setLoading(false) + } + }, [model, provider, t, onChange, ref]) + + return ( + + + + + +`; + +exports[`InputEmbeddingDimension > basic rendering > should match snapshot with loading state 1`] = ` +
+
+
+ + + + + + + + + + +
+
+ +
+
+ +
+`; diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 206f65e262..b4d648dec3 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -47,6 +47,7 @@ const AntdProvider: FC = ({ children }) => { colorBorder: 'var(--color-border)' }, InputNumber: { + controlHeight: 30, colorBorder: 'var(--color-border)' }, Select: { diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index 4dc5f3770f..a80db2659e 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -1,12 +1,9 @@ -import { loggerService } from '@logger' import { db } from '@renderer/databases' import KnowledgeQueue from '@renderer/queue/KnowledgeQueue' import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' -import { RootState } from '@renderer/store' +import { RootState, useAppDispatch } from '@renderer/store' import { addBase, - addFiles as addFilesAction, - addItem, clearAllProcessing, clearCompletedProcessing, deleteBase, @@ -18,19 +15,19 @@ import { updateItemProcessingStatus, updateNotes } from '@renderer/store/knowledge' +import { addFilesThunk, addItemThunk, addNoteThunk } from '@renderer/store/thunk/knowledgeThunk' import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' +import dayjs from 'dayjs' +import { cloneDeep } from 'lodash' import { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { v4 as uuidv4 } from 'uuid' import { useAgents } from './useAgents' import { useAssistants } from './useAssistant' -const logger = loggerService.withContext('useKnowledge') - export const useKnowledge = (baseId: string) => { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId)) // 重命名知识库 @@ -45,71 +42,33 @@ export const useKnowledge = (baseId: string) => { // 批量添加文件 const addFiles = (files: FileMetadata[]) => { - const filesItems: KnowledgeItem[] = files.map((file) => ({ - id: uuidv4(), - type: 'file' as const, - content: file, - created_at: Date.now(), - updated_at: Date.now(), - processingStatus: 'pending', - processingProgress: 0, - processingError: '', - retryCount: 0 - })) - logger.debug('Adding files:', filesItems) - dispatch(addFilesAction({ baseId, items: filesItems })) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) - } - - // 添加URL - const addUrl = (url: string) => { - const newUrlItem: KnowledgeItem = { - id: uuidv4(), - type: 'url' as const, - content: url, - created_at: Date.now(), - updated_at: Date.now(), - processingStatus: 'pending', - processingProgress: 0, - processingError: '', - retryCount: 0 - } - dispatch(addItem({ baseId, item: newUrlItem })) + dispatch(addFilesThunk(baseId, files)) setTimeout(() => KnowledgeQueue.checkAllBases(), 0) } // 添加笔记 const addNote = async (content: string) => { - const noteId = uuidv4() - const note: KnowledgeItem = { - id: noteId, - type: 'note', - content, - created_at: Date.now(), - updated_at: Date.now() - } - - // 存储完整笔记到数据库 - await db.knowledge_notes.add(note) - - // 在 store 中只存储引用 - const noteRef: KnowledgeItem = { - id: noteId, - baseId, - type: 'note', - content: '', // store中不需要存储实际内容 - created_at: Date.now(), - updated_at: Date.now(), - processingStatus: 'pending', - processingProgress: 0, - processingError: '', - retryCount: 0 - } - - dispatch(updateNotes({ baseId, item: noteRef })) + await dispatch(addNoteThunk(baseId, content)) setTimeout(() => KnowledgeQueue.checkAllBases(), 0) } + // 添加URL + const addUrl = (url: string) => { + dispatch(addItemThunk(baseId, 'url', url)) + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + + // 添加 Sitemap + const addSitemap = (url: string) => { + dispatch(addItemThunk(baseId, 'sitemap', url)) + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + + // Add directory support + const addDirectory = (path: string) => { + dispatch(addItemThunk(baseId, 'directory', path)) + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } // 更新笔记内容 const updateNoteContent = async (noteId: string, content: string) => { const note = await db.knowledge_notes.get(noteId) @@ -214,37 +173,62 @@ export const useKnowledge = (baseId: string) => { dispatch(clearAllProcessing({ baseId })) } - // 添加 Sitemap - const addSitemap = (url: string) => { - const newSitemapItem: KnowledgeItem = { - id: uuidv4(), - type: 'sitemap' as const, - content: url, - created_at: Date.now(), - updated_at: Date.now(), - processingStatus: 'pending', - processingProgress: 0, - processingError: '', - retryCount: 0 - } - dispatch(addItem({ baseId, item: newSitemapItem })) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) - } + // 迁移知识库(保留原知识库) + const migrateBase = async (newBase: KnowledgeBase) => { + if (!base) return - // Add directory support - const addDirectory = (path: string) => { - const newDirectoryItem: KnowledgeItem = { - id: uuidv4(), - type: 'directory', - content: path, + const timestamp = dayjs().format('YYMMDDHHmmss') + const newName = `${newBase.name || base.name}-${timestamp}` + + const migratedBase = { + ...cloneDeep(base), // 深拷贝原始知识库 + ...newBase, + id: newBase.id, // 确保使用新的ID + name: newName, created_at: Date.now(), updated_at: Date.now(), - processingStatus: 'pending', - processingProgress: 0, - processingError: '', - retryCount: 0 + items: [] + } as KnowledgeBase + + dispatch(addBase(migratedBase)) + + const files: FileMetadata[] = [] + + // 遍历原知识库的 items,重新添加到新知识库 + for (const item of base.items) { + switch (item.type) { + case 'file': + if (typeof item.content === 'object' && item.content !== null && 'path' in item.content) { + files.push(item.content as FileMetadata) + } + break + case 'note': + try { + const note = await db.knowledge_notes.get(item.id) + const content = (note?.content || '') as string + await dispatch(addNoteThunk(newBase.id, content)) + } catch (error) { + throw new Error(`Failed to migrate note item ${item.id}: ${error}`) + } + break + default: + try { + dispatch(addItemThunk(newBase.id, item.type, item.content as string)) + } catch (error) { + throw new Error(`Failed to migrate item ${item.id}: ${error}`) + } + break + } } - dispatch(addItem({ baseId, item: newDirectoryItem })) + + try { + if (files.length > 0) { + dispatch(addFilesThunk(newBase.id, files)) + } + } catch (error) { + throw new Error(`Failed to migrate files ${files}: ${error}`) + } + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) } @@ -275,6 +259,7 @@ export const useKnowledge = (baseId: string) => { noteItems, renameKnowledgeBase, updateKnowledgeBase, + migrateBase, addFiles, addUrl, addSitemap, diff --git a/src/renderer/src/hooks/useKnowledgeBaseForm.ts b/src/renderer/src/hooks/useKnowledgeBaseForm.ts new file mode 100644 index 0000000000..4b7b000321 --- /dev/null +++ b/src/renderer/src/hooks/useKnowledgeBaseForm.ts @@ -0,0 +1,161 @@ +import { isMac } from '@renderer/config/constant' +import { getEmbeddingMaxContext } from '@renderer/config/embedings' +import { useOcrProviders } from '@renderer/hooks/useOcr' +import { usePreprocessProviders } from '@renderer/hooks/usePreprocess' +import { useProviders } from '@renderer/hooks/useProvider' +import { getModelUniqId } from '@renderer/services/ModelService' +import { KnowledgeBase } from '@renderer/types' +import { nanoid } from 'nanoid' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const createInitialKnowledgeBase = (): KnowledgeBase => ({ + id: nanoid(), + name: '', + model: null as any, // model is required, but will be set by user interaction + items: [], + created_at: Date.now(), + updated_at: Date.now(), + version: 1 +}) + +/** + * A hook that manages the state and handlers for a knowledge base form. + * + * The hook provides: + * - A state object `newBase` that tracks the current form values. + * - A function `setNewBase` to update the form state. + * - A set of handlers for various form actions: + * - `handleEmbeddingModelChange`: Updates the embedding model. + * - `handleRerankModelChange`: Updates the rerank model. + * - `handleDimensionChange`: Updates the dimensions. + * - `handleDocPreprocessChange`: Updates the document preprocess provider. + * - `handleChunkSizeChange`: Updates the chunk size. + * - `handleChunkOverlapChange`: Updates the chunk overlap. + * - `handleThresholdChange`: Updates the threshold. + * @param base - The base knowledge base to use as the initial state. If not provided, an empty base will be used. + * @returns An object containing the new base state, a function to update the base, and handlers for various form actions. + * Also includes provider data for dropdown options and selected provider. + */ +export const useKnowledgeBaseForm = (base?: KnowledgeBase) => { + const { t } = useTranslation() + const [newBase, setNewBase] = useState(base || createInitialKnowledgeBase()) + const { providers } = useProviders() + const { preprocessProviders } = usePreprocessProviders() + const { ocrProviders } = useOcrProviders() + + const selectedDocPreprocessProvider = useMemo( + () => newBase.preprocessOrOcrProvider?.provider, + [newBase.preprocessOrOcrProvider] + ) + + const docPreprocessSelectOptions = useMemo(() => { + const preprocessOptions = { + label: t('settings.tool.preprocess.provider'), + title: t('settings.tool.preprocess.provider'), + options: preprocessProviders + .filter((p) => p.apiKey !== '' || p.id === 'mineru') + .map((p) => ({ value: p.id, label: p.name })) + } + const ocrOptions = { + label: t('settings.tool.ocr.provider'), + title: t('settings.tool.ocr.provider'), + options: ocrProviders.filter((p) => p.apiKey !== '').map((p) => ({ value: p.id, label: p.name })) + } + + return isMac ? [preprocessOptions, ocrOptions] : [preprocessOptions] + }, [ocrProviders, preprocessProviders, t]) + + const handleEmbeddingModelChange = useCallback( + (value: string) => { + const model = providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value) + if (model) { + setNewBase((prev) => ({ ...prev, model })) + } + }, + [providers] + ) + + const handleRerankModelChange = useCallback( + (value: string) => { + const rerankModel = value + ? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value) + : undefined + setNewBase((prev) => ({ ...prev, rerankModel })) + }, + [providers] + ) + + const handleDimensionChange = useCallback((value: number | null) => { + setNewBase((prev) => ({ ...prev, dimensions: value || undefined })) + }, []) + + const handleDocPreprocessChange = useCallback( + (value: string) => { + const type = preprocessProviders.find((p) => p.id === value) ? 'preprocess' : 'ocr' + const provider = (type === 'preprocess' ? preprocessProviders : ocrProviders).find((p) => p.id === value) + if (!provider) { + setNewBase((prev) => ({ ...prev, preprocessOrOcrProvider: undefined })) + return + } + setNewBase((prev) => ({ + ...prev, + preprocessOrOcrProvider: { + type, + provider + } + })) + }, + [preprocessProviders, ocrProviders] + ) + + const handleChunkSizeChange = useCallback( + (value: number | null) => { + const modelId = newBase.model?.id || base?.model?.id + if (!modelId) return + const maxContext = getEmbeddingMaxContext(modelId) + if (!value || !maxContext || value <= maxContext) { + setNewBase((prev) => ({ ...prev, chunkSize: value || undefined })) + } + }, + [newBase.model, base?.model] + ) + + const handleChunkOverlapChange = useCallback( + (value: number | null) => { + if (!value || (newBase.chunkSize && newBase.chunkSize > value)) { + setNewBase((prev) => ({ ...prev, chunkOverlap: value || undefined })) + } else { + window.message.error(t('message.error.chunk_overlap_too_large')) + } + }, + [newBase.chunkSize, t] + ) + + const handleThresholdChange = useCallback( + (value: number | null) => { + setNewBase((prev) => ({ ...prev, threshold: value || undefined })) + }, + [setNewBase] + ) + + const handlers = { + handleEmbeddingModelChange, + handleRerankModelChange, + handleDimensionChange, + handleDocPreprocessChange, + handleChunkSizeChange, + handleChunkOverlapChange, + handleThresholdChange + } + + const providerData = { + providers, + preprocessProviders, + ocrProviders, + selectedDocPreprocessProvider, + docPreprocessSelectOptions + } + + return { newBase, setNewBase, handlers, providerData } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e12a796559..b85f02de08 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -804,11 +804,11 @@ "dimensions": "Embedding dimension", "dimensions_auto_set": "Auto-set embedding dimensions", "dimensions_default": "The model will use default embedding dimensions", - "dimensions_error_invalid": "Please enter embedding dimension size", + "dimensions_error_invalid": "Invalid embedding dimension", "dimensions_set_right": "⚠️ Please ensure the model supports the set embedding dimension size", - "dimensions_size_placeholder": " Embedding dimension size, e.g. 1024", + "dimensions_size_placeholder": "Leave empty to not pass dimensions", "dimensions_size_too_large": "The embedding dimension cannot exceed the model's context limit ({{max_context}}).", - "dimensions_size_tooltip": "The size of the embedding dimension; the larger the value, the larger the embedding dimension, but it also consumes more tokens.", + "dimensions_size_tooltip": "Embedding dimension size, the larger the value, the more tokens will be consumed. Leave empty to not pass dimensions parameter.", "directories": "Directories", "directory_placeholder": "Enter Directory Path", "document_count": "Requested Document Chunks", @@ -817,13 +817,35 @@ "drag_file": "Drag file here", "edit_remark": "Edit Remark", "edit_remark_placeholder": "Please enter remark content", + "embedding_model": "Embedding Model", "embedding_model_required": "Knowledge Base Embedding Model is required", "empty": "No knowledge base found", + "error": { + "failed_to_create": "Knowledge base creation failed", + "failed_to_edit": "Knowledge base editing failed" + }, "file_hint": "Support {{file_types}}", "index_all": "Index All", "index_cancelled": "Indexing cancelled", "index_started": "Indexing started", "invalid_url": "Invalid URL", + "migrate": { + "button": { + "text": "Migrate" + }, + "confirm": { + "content": "Detected changes in embedding model or dimension, cannot save configuration directly. Knowledge base migration will not delete the existing knowledge base, but will create a copy and then reprocess all knowledge base entries, which may consume a large number of tokens, please proceed with caution.", + "ok": "Start Migration", + "title": "Knowledge Base Migration" + }, + "error": { + "failed": "Migration failed" + }, + "source_dimensions": "Source Dimensions", + "source_model": "Source Model", + "target_dimensions": "Target Dimensions", + "target_model": "Target Model" + }, "model_info": "Model Info", "name_required": "Knowledge Base Name is required", "no_bases": "No knowledge bases available", @@ -833,6 +855,7 @@ "not_support": "Knowledge base database engine updated, the knowledge base will no longer be supported, please create a new knowledge base", "notes": "Notes", "notes_placeholder": "Enter additional information or context for this knowledge base...", + "provider_not_found": "Provider not found", "quota": "{{name}} Left Quota: {{quota}}", "quota_infinity": "{{name}} Quota: Unlimited", "rename": "Rename", @@ -3226,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "Failed to auto-obtain dimensions", - "embedding_model_required": "Please select an embedding model first", - "provider_not_found": "Provider not found", "rag_failed": "RAG failed" }, "info": { @@ -3244,11 +3264,6 @@ "document_count": { "label": "Document Chunks Count", "tooltip": "Expected number of document chunks to extract from each search result, the actual total number of extracted document chunks is this value multiplied by the number of search results." - }, - "embedding_dimensions": { - "auto_get": "Auto Get Dimensions", - "placeholder": "Leave empty", - "tooltip": "If left blank, the dimensions parameter will not be passed" } }, "title": "Search Result Compression" diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 83d70549e6..061bf53ddf 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -804,11 +804,11 @@ "dimensions": "埋め込み次元", "dimensions_auto_set": "埋め込み次元を自動設定", "dimensions_default": "モデルはデフォルトの埋め込み次元を使用します", - "dimensions_error_invalid": "埋め込み次元のサイズを入力してください", + "dimensions_error_invalid": "無効な埋め込み次元", "dimensions_set_right": "⚠️ モデルが設定した埋め込み次元のサイズをサポートしていることを確認してください", - "dimensions_size_placeholder": " 埋め込み次元のサイズ(例:1024)", + "dimensions_size_placeholder": "次元数を設定しない場合は空欄のままにしてください", "dimensions_size_too_large": "埋め込み次元はモデルのコンテキスト制限({{max_context}})を超えてはなりません。", - "dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど埋め込み次元も大きくなりますが、消費するトークンも増えます。", + "dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど消費するトークンも増えます。空欄の場合はdimensionsパラメータを渡しません。", "directories": "ディレクトリ", "directory_placeholder": "ディレクトリパスを入力", "document_count": "要求されたドキュメント分段数", @@ -817,13 +817,35 @@ "drag_file": "ファイルをここにドラッグ", "edit_remark": "備考を編集", "edit_remark_placeholder": "備考内容を入力してください", + "embedding_model": "埋め込みモデル", "embedding_model_required": "ナレッジベース埋め込みモデルが必要です", "empty": "ナレッジベースが見つかりません", + "error": { + "failed_to_create": "ナレッジベースの作成に失敗しました", + "failed_to_edit": "ナレッジベースの編集に失敗しました" + }, "file_hint": "{{file_types}} 形式をサポート", "index_all": "すべてをインデックス", "index_cancelled": "インデックスがキャンセルされました", "index_started": "インデックスを開始", "invalid_url": "無効なURL", + "migrate": { + "button": { + "text": "移行" + }, + "confirm": { + "content": "埋め込みモデルまたは次元に変更が検出されました。設定を直接保存することはできませんが、移行を実行できます。ナレッジベースの移行では古いナレッジベースは削除されず、代わりにコピーを作成してすべてのエントリを再処理します。大量のトークンを消費する可能性があるため、慎重に操作してください。", + "ok": "移行を開始", + "title": "ナレッジベースの移行" + }, + "error": { + "failed": "移行が失敗しました" + }, + "source_dimensions": "ソース次元", + "source_model": "ソースモデル", + "target_dimensions": "ターゲット次元", + "target_model": "ターゲットモデル" + }, "model_info": "モデル情報", "name_required": "ナレッジベース名は必須です", "no_bases": "ナレッジベースがありません", @@ -833,6 +855,7 @@ "not_support": "ナレッジベースデータベースエンジンが更新されました。このナレッジベースはもうサポートされていません。新しいナレッジベースを作成してください", "notes": "ノート", "notes_placeholder": "このナレッジベースの追加情報やコンテキストを入力...", + "provider_not_found": "プロバイダーが見つかりません", "quota": "{{name}} 残りクォータ: {{quota}}", "quota_infinity": "{{name}} クォータ: 無制限", "rename": "名前を変更", @@ -3226,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "次元の自動取得に失敗しました", - "embedding_model_required": "まず埋め込みモデルを選択してください", - "provider_not_found": "プロバイダーが見つかりません", "rag_failed": "RAG に失敗しました" }, "info": { @@ -3244,11 +3264,6 @@ "document_count": { "label": "文書チャンク数", "tooltip": "単一の検索結果から抽出する文書チャンク数。実際に抽出される文書チャンク数は、この値に検索結果数を乗じたものです。" - }, - "embedding_dimensions": { - "auto_get": "次元を自動取得", - "placeholder": "次元を設定しない", - "tooltip": "空の場合、dimensions パラメーターは渡されません" } }, "title": "検索結果の圧縮" diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 5f35364084..6b31df5b1b 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -804,11 +804,11 @@ "dimensions": "векторное пространство", "dimensions_auto_set": "Автоматическая установка размерности эмбеддинга", "dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию", - "dimensions_error_invalid": "Пожалуйста, введите размерность эмбеддинга", + "dimensions_error_invalid": "Неверная размерность эмбеддинга", "dimensions_set_right": "⚠️ Убедитесь, что модель поддерживает заданный размер эмбеддинга", - "dimensions_size_placeholder": " Размерность эмбеддинга, например 1024", + "dimensions_size_placeholder": "Оставьте пустым, чтобы не устанавливать", "dimensions_size_too_large": "Размерность вложения не может превышать ограничение контекста модели ({{max_context}})", - "dimensions_size_tooltip": "Размерность вложения, чем больше значение, тем больше размерность вложения, но и потребляемых токенов также становится больше.", + "dimensions_size_tooltip": "Размерность вложения - чем больше значение, тем больше токенов потребляется. Если оставить пустым, параметр dimensions не будет передаваться.", "directories": "Директории", "directory_placeholder": "Введите путь к директории", "document_count": "Количество запрошенных документов", @@ -817,13 +817,35 @@ "drag_file": "Перетащите файл сюда", "edit_remark": "Изменить примечание", "edit_remark_placeholder": "Пожалуйста, введите содержание примечания", + "embedding_model": "Модель встраивания", "embedding_model_required": "Модель встраивания базы знаний требуется", "empty": "База знаний не найдена", + "error": { + "failed_to_create": "Создание базы знаний завершено с ошибками", + "failed_to_edit": "Редактирование базы знаний завершено с ошибками" + }, "file_hint": "Поддерживаются {{file_types}}", "index_all": "Индексировать все", "index_cancelled": "Индексирование отменено", "index_started": "Индексирование началось", "invalid_url": "Неверный URL", + "migrate": { + "button": { + "text": "Миграция" + }, + "confirm": { + "content": "Обнаружена изменение модели встраивания или размерности, невозможно сохранить конфигурацию напрямую. Миграция базы знаний не удалит существующую базу знаний, а создаст ее копию, после чего перепроцессит все записи базы знаний, что может потреблять большое количество токенов. Пожалуйста, действуйте осторожно.", + "ok": "Начать миграцию", + "title": "Миграция базы знаний" + }, + "error": { + "failed": "Миграция завершена с ошибками" + }, + "source_dimensions": "Исходная размерность", + "source_model": "Исходная модель", + "target_dimensions": "Целевая размерность", + "target_model": "Целевая модель" + }, "model_info": "Модель информации", "name_required": "Название базы знаний обязательно", "no_bases": "База знаний не найдена", @@ -833,6 +855,7 @@ "not_support": "База знаний базы данных движок обновлен, база знаний больше не поддерживается, пожалуйста, создайте новую базу знаний", "notes": "Заметки", "notes_placeholder": "Введите дополнительную информацию или контекст для этой базы знаний...", + "provider_not_found": "Поставщик не найден", "quota": "{{name}} Остаток квоты: {{quota}}", "quota_infinity": "{{name}} Квота: Не ограничена", "rename": "Переименовать", @@ -3226,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "Не удалось получить размерности", - "embedding_model_required": "Пожалуйста, сначала выберите модель встраивания", - "provider_not_found": "Поставщик не найден", "rag_failed": "RAG не удалось" }, "info": { @@ -3244,11 +3264,6 @@ "document_count": { "label": "Количество фрагментов документов", "tooltip": "Ожидаемое количество фрагментов документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных фрагментов документов равно этому значению, умноженному на количество результатов поиска." - }, - "embedding_dimensions": { - "auto_get": "Автоматически получить размерности", - "placeholder": "Не устанавливать размерности", - "tooltip": "Если оставить пустым, параметр dimensions не будет передан" } }, "title": "Сжатие результатов поиска" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 68064e6466..0c986e1f94 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -804,11 +804,11 @@ "dimensions": "嵌入维度", "dimensions_auto_set": "自动设置嵌入维度", "dimensions_default": "模型将使用默认嵌入维度", - "dimensions_error_invalid": "请输入嵌入维度大小", + "dimensions_error_invalid": "无效的嵌入维度", "dimensions_set_right": "⚠️ 请确保模型支持所设置的嵌入维度大小", - "dimensions_size_placeholder": "嵌入维度大小,如 1024", + "dimensions_size_placeholder": "留空表示不设置", "dimensions_size_too_large": "嵌入维度不能超过模型上下文限制({{max_context}})", - "dimensions_size_tooltip": "嵌入维度大小,数值越大,嵌入维度越大,但消耗的 Token 也越多", + "dimensions_size_tooltip": "嵌入维度大小,数值越大消耗的 Token 也越多。留空则不传递 dimensions 参数。", "directories": "目录", "directory_placeholder": "请输入目录路径", "document_count": "请求文档片段数量", @@ -817,13 +817,35 @@ "drag_file": "拖拽文件到这里", "edit_remark": "修改备注", "edit_remark_placeholder": "请输入备注内容", + "embedding_model": "嵌入模型", "embedding_model_required": "知识库嵌入模型是必需的", "empty": "暂无知识库", + "error": { + "failed_to_create": "知识库创建失败", + "failed_to_edit": "知识库编辑失败" + }, "file_hint": "支持 {{file_types}} 格式", "index_all": "索引全部", "index_cancelled": "索引已取消", "index_started": "索引开始", "invalid_url": "无效的网址", + "migrate": { + "button": { + "text": "迁移" + }, + "confirm": { + "content": "检测到嵌入模型或维度有变更,无法直接保存配置,可以执行迁移。知识库迁移不会删除旧知识库,而是创建一个副本之后重新处理所有知识库条目,可能消耗大量 tokens,请谨慎操作。", + "ok": "开始迁移", + "title": "知识库迁移" + }, + "error": { + "failed": "迁移失败" + }, + "source_dimensions": "源维度", + "source_model": "源模型", + "target_dimensions": "目标维度", + "target_model": "目标模型" + }, "model_info": "模型信息", "name_required": "知识库名称为必填项", "no_bases": "暂无知识库", @@ -833,6 +855,7 @@ "not_support": "知识库数据库引擎已更新,该知识库将不再支持,请重新创建知识库", "notes": "笔记", "notes_placeholder": "输入此知识库的附加信息或上下文...", + "provider_not_found": "未找到服务商", "quota": "{{name}} 剩余额度:{{quota}}", "quota_infinity": "{{name}} 剩余额度:无限制", "rename": "重命名", @@ -3226,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "维度自动获取失败", - "embedding_model_required": "请先选择嵌入模型", - "provider_not_found": "未找到服务商", "rag_failed": "RAG 失败" }, "info": { @@ -3244,11 +3264,6 @@ "document_count": { "label": "文档片段数量", "tooltip": "预期从单个搜索结果中提取的文档片段数量,实际提取的总数量是这个值乘以搜索结果数量。" - }, - "embedding_dimensions": { - "auto_get": "自动获取维度", - "placeholder": "不设置维度", - "tooltip": "留空则不传递 dimensions 参数" } }, "title": "搜索结果压缩" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index c23b7d0080..2f885bd0d0 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -804,11 +804,11 @@ "dimensions": "嵌入維度", "dimensions_auto_set": "自動設定嵌入維度", "dimensions_default": "模型將使用預設嵌入維度", - "dimensions_error_invalid": "請輸入嵌入維度大小", + "dimensions_error_invalid": "無效的嵌入維度", "dimensions_set_right": "⚠️ 請確保模型支援所設置的嵌入維度大小", - "dimensions_size_placeholder": " 嵌入維度大小,例如 1024", + "dimensions_size_placeholder": "留空表示不設置", "dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})", - "dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多", + "dimensions_size_tooltip": "嵌入維度大小,數值越大消耗的 Token 也越多。留空則不傳遞 dimensions 參數。", "directories": "目錄", "directory_placeholder": "請輸入目錄路徑", "document_count": "請求文件片段數量", @@ -817,13 +817,35 @@ "drag_file": "拖拽檔案到這裡", "edit_remark": "修改備註", "edit_remark_placeholder": "請輸入備註內容", + "embedding_model": "嵌入模型", "embedding_model_required": "知識庫嵌入模型是必需的", "empty": "暫無知識庫", + "error": { + "failed_to_create": "知識庫創建失敗", + "failed_to_edit": "知識庫編輯失敗" + }, "file_hint": "支援 {{file_types}} 格式", "index_all": "索引全部", "index_cancelled": "索引已取消", "index_started": "索引開始", "invalid_url": "無效的網址", + "migrate": { + "button": { + "text": "遷移" + }, + "confirm": { + "content": "檢測到嵌入模型或維度有變更,無法直接保存配置,可以執行遷移。知識庫遷移不會刪除舊知識庫,而是建立一個副本之後重新處理所有知識庫條目,可能消耗大量 tokens,請謹慎操作。", + "ok": "開始遷移", + "title": "知識庫遷移" + }, + "error": { + "failed": "遷移失敗" + }, + "source_dimensions": "源維度", + "source_model": "源模型", + "target_dimensions": "目標維度", + "target_model": "目標模型" + }, "model_info": "模型資訊", "name_required": "知識庫名稱為必填項目", "no_bases": "暫無知識庫", @@ -833,6 +855,7 @@ "not_support": "知識庫資料庫引擎已更新,該知識庫將不再支援,請重新建立知識庫", "notes": "筆記", "notes_placeholder": "輸入此知識庫的附加資訊或上下文...", + "provider_not_found": "未找到服務商", "quota": "{{name}} 剩餘配額:{{quota}}", "quota_infinity": "{{name}} 配額:無限制", "rename": "重新命名", @@ -3226,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "維度自動獲取失敗", - "embedding_model_required": "請先選擇嵌入模型", - "provider_not_found": "未找到服務商", "rag_failed": "RAG 失敗" }, "info": { @@ -3244,11 +3264,6 @@ "document_count": { "label": "文檔片段數量", "tooltip": "預期從單個搜尋結果中提取的文檔片段數量,實際提取的總數量是這個值乘以搜尋結果數量。" - }, - "embedding_dimensions": { - "auto_get": "自動獲取維度", - "placeholder": "不設置維度", - "tooltip": "留空則不傳遞 dimensions 參數" } }, "title": "搜尋結果壓縮" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index c54454e3b9..3ccd5822f2 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -817,13 +817,35 @@ "drag_file": "Βάλτε το αρχείο εδώ", "edit_remark": "Μεταβολή σημειώματος", "edit_remark_placeholder": "Εισάγετε το σημείωμα", + "embedding_model": "Μοντέλο ενσωμάτωσης", "embedding_model_required": "Το μοντέλο ενσωμάτωσης της βάσης γνώσης είναι υποχρεωτικό", "empty": "Λεηλασία βάσης γνώσεων", + "error": { + "failed_to_create": "Αποτυχία δημιουργίας βάσης γνώσεων", + "failed_to_edit": "Αποτυχία επεξεργασίας βάσης γνώσεων" + }, "file_hint": "Υποστηρίζεται το {{file_types}} μορφάττων", "index_all": "Ευρετήριοποίηση όλων", "index_cancelled": "Η ευρετήριοποίηση διακόπηκε", "index_started": "Η ευρετήριοποίηση ξεκίνησε", "invalid_url": "Μη έγκυρη διευθύνση", + "migrate": { + "button": { + "text": "Μεταφορά" + }, + "confirm": { + "content": "Εντοπίστηκαν αλλαγές στο μοντέλο ενσωμάτωσης ή τις διαστάσεις, οπότε δεν είναι δυνατή η αποθήκευση των ρυθμίσεων. Μπορείτε να εκτελέσετε μεταφορά για να αποφύγετε την απώλεια δεδομένων. Η μεταφορά της βάσης γνώσεων δεν διαγράφει την παλιά βάση γνώσεων, αλλά δημιουργεί ένα αντίγραφο και επεξεργάζεται όλα τα στοιχεία της βάσης γνώσεων, η οποία μπορεί να καταναλώσει πολλές μονάδες (Tokens). Παρακαλώ είστε προσεκτικοί.", + "ok": "Ξεκινήστε τη μεταφορά", + "title": "Μεταφορά βάσης γνώσεων" + }, + "error": { + "failed": "Αποτυχία μεταφοράς" + }, + "source_dimensions": "Πηγαίες διαστάσεις", + "source_model": "Πηγαίο μοντέλο", + "target_dimensions": "Προορισμένες διαστάσεις", + "target_model": "Προορισμένο μοντέλο" + }, "model_info": "Πληροφορίες μοντέλου", "name_required": "Το όνομα της βάσης γνώσης είναι υποχρεωτικό", "no_bases": "Λεηλασία βάσης γνώσεων", @@ -833,6 +855,7 @@ "not_support": "Το μοντέλο βάσης γνώσεων έχει ενημερωθεί, αυτή η βάση γνώσεων δεν θα υποστηρίζεται πλέον, παρακαλείστε να δημιουργήσετε ξανά μια βάση γνώσεων", "notes": "Σημειώματα", "notes_placeholder": "Εισάγετε πρόσθετες πληροφορίες ή πληροφορίες προσδιορισμού για αυτή τη βάση γνώσεων...", + "provider_not_found": "Η παροχή υπηρεσιών μοντέλου βάσης γνώσεων χαθηκε, αυτή η βάση γνώσεων δεν θα υποστηρίζεται πλέον, παρακαλείστε να δημιουργήσετε ξανά μια βάση γνώσεων", "quota": "Διαθέσιμο όριο για {{name}}: {{quota}}", "quota_infinity": "Διαθέσιμο όριο για {{name}}: Απεριόριστο", "rename": "Μετονομασία", @@ -3076,6 +3099,7 @@ "search_placeholder": "Αναζήτηση ID ή ονόματος μονάδας", "title": "Υπηρεσία μονάδων", "vertex_ai": { + "api_host_help": "[to be translated]:Vertex AI 的 API 地址,不建议填写,通常适用于反向代理", "documentation": "Δείτε την επίσημη τεκμηρίωση για περισσότερες λεπτομέρειες ρύθμισης:", "learn_more": "Μάθετε περισσότερα", "location": "Περιοχή", @@ -3225,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "Αποτυχία αυτόματης λήψης διαστάσεων", - "embedding_model_required": "Παρακαλώ επιλέξτε πρώτα ένα μοντέλο ενσωμάτωσης", - "provider_not_found": "Ο πάροχος δεν βρέθηκε", "rag_failed": "Το RAG απέτυχε" }, "info": { @@ -3243,11 +3264,6 @@ "document_count": { "label": "Αριθμός αποσπασμάτων εγγράφου", "tooltip": "Ο αναμενόμενος αριθμός αποσπασμάτων εγγράφου που θα εξαχθούν από κάθε αποτέλεσμα αναζήτησης· ο πραγματικός συνολικός αριθμός είναι αυτή η τιμή επί τον αριθμό των αποτελεσμάτων αναζήτησης" - }, - "embedding_dimensions": { - "auto_get": "Αυτόματη λήψη διαστάσεων", - "placeholder": "Χωρίς καθορισμό διαστάσεων", - "tooltip": "Αν αφεθεί κενό, δεν θα μεταδοθεί η παράμετρος dimensions" } }, "title": "Συμπίεση αποτελεσμάτων αναζήτησης" diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index ec209de8c2..bee9fc0bf2 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -817,13 +817,35 @@ "drag_file": "Arrastre archivos aquí", "edit_remark": "Editar observación", "edit_remark_placeholder": "Ingrese el contenido de la observación", + "embedding_model": "Modelo de incrustación", "embedding_model_required": "El modelo de incrustación de la base de conocimientos es obligatorio", "empty": "Sin bases de conocimientos", + "error": { + "failed_to_create": "Error al crear la base de conocimientos", + "failed_to_edit": "Error al editar la base de conocimientos" + }, "file_hint": "Formatos soportados: {{file_types}}", "index_all": "Indexar todo", "index_cancelled": "Índice cancelado", "index_started": "Índice iniciado", "invalid_url": "URL inválida", + "migrate": { + "button": { + "text": "Migrar" + }, + "confirm": { + "content": "Se detectaron cambios en el modelo de incrustación o las dimensiones, por lo que no se puede guardar la configuración. Puede ejecutar la migración para evitar la pérdida de datos. La migración de la base de conocimientos no elimina la base de conocimientos anterior, sino que crea una copia y procesa todos los elementos de la base de conocimientos, lo que puede consumir muchos tokens. Por favor, tenga cuidado.", + "ok": "Iniciar migración", + "title": "Migración de base de conocimientos" + }, + "error": { + "failed": "Error en la migración" + }, + "source_dimensions": "Dimensiones de origen", + "source_model": "Modelo de origen", + "target_dimensions": "Dimensiones de destino", + "target_model": "Modelo de destino" + }, "model_info": "Información del modelo", "name_required": "El nombre de la base de conocimientos es obligatorio", "no_bases": "Sin bases de conocimientos", @@ -833,6 +855,7 @@ "not_support": "El motor de base de datos de la base de conocimientos ha sido actualizado, esta base de conocimientos ya no es compatible, por favor cree una nueva base de conocimientos", "notes": "Notas", "notes_placeholder": "Ingrese información adicional o contexto para esta base de conocimientos...", + "provider_not_found": "El proveedor del modelo de la base de conocimientos ha sido perdido, esta base de conocimientos ya no es compatible, por favor cree una nueva base de conocimientos", "quota": "Cupo restante de {{name}}: {{quota}}", "quota_infinity": "Cupo restante de {{name}}: ilimitado", "rename": "Renombrar", @@ -3076,6 +3099,7 @@ "search_placeholder": "Buscar ID o nombre del modelo", "title": "Servicio de modelos", "vertex_ai": { + "api_host_help": "[to be translated]:Vertex AI 的 API 地址,不建议填写,通常适用于反向代理", "documentation": "Consulte la documentación oficial para obtener más detalles de configuración:", "learn_more": "Más información", "location": "Región", @@ -3225,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "Error al obtener automáticamente las dimensiones", - "embedding_model_required": "Por favor, seleccione primero un modelo de incrustación", - "provider_not_found": "Proveedor no encontrado", "rag_failed": "RAG fallido" }, "info": { @@ -3243,11 +3264,6 @@ "document_count": { "label": "Número de fragmentos de documento", "tooltip": "Número esperado de fragmentos de documento extraídos de un único resultado de búsqueda; el número total extraído será este valor multiplicado por la cantidad de resultados de búsqueda" - }, - "embedding_dimensions": { - "auto_get": "Obtener automáticamente las dimensiones", - "placeholder": "Sin configuración de dimensiones", - "tooltip": "Si se deja vacío, no se enviará el parámetro dimensions" } }, "title": "Compresión de resultados de búsqueda" diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index b711ded830..6d05e213db 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -817,13 +817,35 @@ "drag_file": "Glissez-déposez un fichier ici", "edit_remark": "Modifier la remarque", "edit_remark_placeholder": "Entrez le contenu de la remarque", + "embedding_model": "Modèle d'intégration", "embedding_model_required": "Le modèle d'intégration de la base de connaissances est obligatoire", "empty": "Aucune base de connaissances pour le moment", + "error": { + "failed_to_create": "Erreur lors de la création de la base de connaissances", + "failed_to_edit": "Erreur lors de la modification de la base de connaissances" + }, "file_hint": "Format supporté : {{file_types}}", "index_all": "Indexer tout", "index_cancelled": "L'indexation a été annulée", "index_started": "L'indexation a commencé", "invalid_url": "URL invalide", + "migrate": { + "button": { + "text": "Migrer" + }, + "confirm": { + "content": "Des modifications ont été détectées dans le modèle d'intégration ou les dimensions, ce qui empêche la sauvegarde de la configuration. Vous pouvez exécuter la migration pour éviter la perte de données. La migration de la base de connaissances ne supprime pas la base de connaissances précédente, mais crée une copie et traite tous les éléments de la base de connaissances, ce qui peut consommer beaucoup de jetons. Veuillez agir avec prudence.", + "ok": "Commencer la migration", + "title": "Migration de la base de connaissances" + }, + "error": { + "failed": "Erreur lors de la migration" + }, + "source_dimensions": "Dimensions source", + "source_model": "Modèle source", + "target_dimensions": "Dimensions cible", + "target_model": "Modèle cible" + }, "model_info": "Informations sur le modèle", "name_required": "Le nom de la base de connaissances est obligatoire", "no_bases": "Aucune base de connaissances pour le moment", @@ -833,6 +855,7 @@ "not_support": "Le moteur de base de données de la base de connaissances a été mis à jour, cette base de connaissances ne sera plus supportée, veuillez en créer une nouvelle", "notes": "Notes", "notes_placeholder": "Entrez des informations supplémentaires ou un contexte pour cette base de connaissances...", + "provider_not_found": "Le fournisseur du modèle de la base de connaissances a été perdu, cette base de connaissances ne sera plus supportée, veuillez en créer une nouvelle", "quota": "Quota restant pour {{name}} : {{quota}}", "quota_infinity": "Quota restant pour {{name}} : illimité", "rename": "Renommer", @@ -3076,6 +3099,7 @@ "search_placeholder": "Rechercher un ID ou un nom de modèle", "title": "Services de modèles", "vertex_ai": { + "api_host_help": "[to be translated]:Vertex AI 的 API 地址,不建议填写,通常适用于反向代理", "documentation": "Consultez la documentation officielle pour plus de détails sur la configuration :", "learn_more": "En savoir plus", "location": "Région", @@ -3225,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "Échec de l'obtention automatique des dimensions", - "embedding_model_required": "Veuillez d'abord sélectionner un modèle d'incorporation", - "provider_not_found": "Fournisseur non trouvé", "rag_failed": "Échec du RAG" }, "info": { @@ -3243,11 +3264,6 @@ "document_count": { "label": "Nombre de fragments de document", "tooltip": "Nombre prévu de fragments de document à extraire d'un seul résultat de recherche. Le nombre total réellement extrait est ce nombre multiplié par le nombre de résultats de recherche." - }, - "embedding_dimensions": { - "auto_get": "Obtenir automatiquement les dimensions", - "placeholder": "Ne pas définir de dimension", - "tooltip": "Laisser vide pour ne pas transmettre le paramètre dimensions" } }, "title": "Compression des résultats de recherche" diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 16cf91d5fd..6c1e3166d3 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -817,13 +817,35 @@ "drag_file": "Arraste o arquivo aqui", "edit_remark": "Editar observação", "edit_remark_placeholder": "Digite o conteúdo da observação", + "embedding_model": "Modelo de incorporação", "embedding_model_required": "O modelo de incorporação da base de conhecimento é obrigatório", "empty": "Sem repositório de conhecimento", + "error": { + "failed_to_create": "Falha ao criar o repositório de conhecimento", + "failed_to_edit": "Falha ao editar o repositório de conhecimento" + }, "file_hint": "Formatos suportados: {{file_types}}", "index_all": "Índice total", "index_cancelled": "Índice cancelado", "index_started": "Índice iniciado", "invalid_url": "URL inválida", + "migrate": { + "button": { + "text": "Migrar" + }, + "confirm": { + "content": "Foram detectadas alterações no modelo de incorporação ou dimensões, o que impede a gravação da configuração. Você pode executar a migração para evitar a perda de dados. A migração do repositório de conhecimento não exclui o repositório de conhecimento anterior, mas cria uma cópia e processa todos os itens do repositório de conhecimento, o que pode consumir muitos tokens. Por favor, agir com cuidado.", + "ok": "Iniciar migração", + "title": "Migração do repositório de conhecimento" + }, + "error": { + "failed": "Falha na migração" + }, + "source_dimensions": "Dimensões de origem", + "source_model": "Modelo de origem", + "target_dimensions": "Dimensões de destino", + "target_model": "Modelo de destino" + }, "model_info": "Informações do modelo", "name_required": "O nome da base de conhecimento é obrigatório", "no_bases": "Sem repositório de conhecimento", @@ -833,6 +855,7 @@ "not_support": "O motor de banco de dados do repositório de conhecimento foi atualizado, este repositório de conhecimento não será mais suportado, por favor, crie um novo repositório de conhecimento", "notes": "Notas", "notes_placeholder": "Digite informações adicionais ou contexto para este repositório de conhecimento...", + "provider_not_found": "O provedor do modelo do repositório de conhecimento foi perdido, este repositório de conhecimento não será mais suportado, por favor, crie um novo repositório de conhecimento", "quota": "Cota restante de {{name}}: {{quota}}", "quota_infinity": "Cota restante de {{name}}: ilimitada", "rename": "Renomear", @@ -3076,6 +3099,7 @@ "search_placeholder": "Procurar ID ou nome do modelo", "title": "Serviços de Modelos", "vertex_ai": { + "api_host_help": "[to be translated]:Vertex AI 的 API 地址,不建议填写,通常适用于反向代理", "documentation": "Consulte a documentação oficial para obter mais detalhes de configuração:", "learn_more": "Saiba mais", "location": "Região", @@ -3225,9 +3249,6 @@ } }, "error": { - "dimensions_auto_failed": "Falha ao obter automaticamente as dimensões", - "embedding_model_required": "Por favor, selecione primeiro o modelo de incorporação", - "provider_not_found": "Provedor não encontrado", "rag_failed": "RAG falhou" }, "info": { @@ -3243,11 +3264,6 @@ "document_count": { "label": "Número de fragmentos de documentos", "tooltip": "Número esperado de fragmentos de documentos a serem extraídos de um único resultado de pesquisa. O número total real extraído será esse valor multiplicado pelo número de resultados de pesquisa." - }, - "embedding_dimensions": { - "auto_get": "Obter automaticamente dimensões", - "placeholder": "Não definir dimensões", - "tooltip": "Se deixado em branco, o parâmetro dimensions não será enviado" } }, "title": "Compressão de resultados de pesquisa" diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index e8948508e3..d0c15d5540 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -12,8 +12,8 @@ import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import EditKnowledgeBasePopup from './components/EditKnowledgeBasePopup' import KnowledgeSearchPopup from './components/KnowledgeSearchPopup' -import KnowledgeSettings from './components/KnowledgeSettings' import QuotaTag from './components/QuotaTag' import KnowledgeDirectories from './items/KnowledgeDirectories' import KnowledgeFiles from './items/KnowledgeFiles' @@ -126,7 +126,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { + +
{children}
+
+ + +
+ + ) : null, + Menu: ({ items, defaultSelectedKeys, onSelect, ...props }: any) => ( +
+ {items?.map((item: any) => ( +
onSelect?.({ key: item.key })} + style={{ cursor: 'pointer' }}> + {item.label} +
+ ))} +
+ ) +})) + +/** + * 创建测试用的面板配置 + * @param overrides 可选的属性覆盖 + * @returns PanelConfig 数组 + */ +function createPanelConfigs(overrides: Partial[] = []): PanelConfig[] { + const defaultPanels: PanelConfig[] = [ + { + key: 'general', + label: 'General Settings', + panel:
General Settings Panel
+ }, + { + key: 'advanced', + label: 'Advanced Settings', + panel:
Advanced Settings Panel
+ } + ] + + return defaultPanels.map((panel, index) => ({ + ...panel, + ...overrides[index] + })) +} + +/** + * 渲染 KnowledgeBaseFormModal 组件的辅助函数 + * @param props 可选的组件属性 + * @returns render 结果 + */ +function renderModal(props: Partial = {}) { + const defaultProps = { + open: true, + title: 'Knowledge Base Settings', + panels: createPanelConfigs(), + onCancel: mocks.onCancel, + onOk: mocks.onOk + } + + return render() +} + +describe('KnowledgeBaseFormModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('basic rendering', () => { + it('should match snapshot', () => { + const { container } = renderModal() + expect(container.firstChild).toMatchSnapshot() + }) + + it('should render modal when open is true', () => { + renderModal({ open: true }) + + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByTestId('hstack')).toBeInTheDocument() + expect(screen.getByTestId('menu')).toBeInTheDocument() + }) + + it('should render first panel by default', () => { + renderModal() + + expect(screen.getByTestId('general-panel')).toBeInTheDocument() + expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument() + }) + + it('should handle empty panels array', () => { + renderModal({ panels: [] }) + + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByTestId('menu')).toBeInTheDocument() + }) + }) + + describe('menu interaction', () => { + it('should switch panels when menu item is clicked', () => { + renderModal() + + // Initially shows general panel + expect(screen.getByTestId('general-panel')).toBeInTheDocument() + expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument() + + // Click advanced menu item + fireEvent.click(screen.getByTestId('menu-item-advanced')) + + // Should now show advanced panel + expect(screen.queryByTestId('general-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('advanced-panel')).toBeInTheDocument() + }) + + it('should set default selected menu to first panel key', () => { + const panels = createPanelConfigs() + renderModal({ panels }) + + const menu = screen.getByTestId('menu') + expect(menu).toHaveAttribute('data-default-selected', panels[0].key) + }) + + it('should handle menu selection with custom panels', () => { + const customPanels: PanelConfig[] = [ + { + key: 'custom1', + label: 'Custom Panel 1', + panel:
Custom Panel 1
+ }, + { + key: 'custom2', + label: 'Custom Panel 2', + panel:
Custom Panel 2
+ } + ] + + renderModal({ panels: customPanels }) + + // Initially shows first custom panel + expect(screen.getByTestId('custom1-panel')).toBeInTheDocument() + + // Click second custom menu item + fireEvent.click(screen.getByTestId('menu-item-custom2')) + + // Should now show second custom panel + expect(screen.queryByTestId('custom1-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('custom2-panel')).toBeInTheDocument() + }) + }) + + describe('modal props', () => { + const user = userEvent.setup() + it('should pass through modal props correctly', () => { + const customTitle = 'Custom Modal Title' + renderModal({ title: customTitle }) + + const modal = screen.getByTestId('modal') + expect(modal).toHaveAttribute('data-title', customTitle) + }) + + it('should call onOk when ok button is clicked', async () => { + renderModal() + + await user.click(screen.getByTestId('modal-ok')) + expect(mocks.onOk).toHaveBeenCalledTimes(1) + }) + }) + + describe('edge cases', () => { + it('should handle single panel', () => { + const singlePanel: PanelConfig[] = [ + { + key: 'only', + label: 'Only Panel', + panel:
Only Panel
+ } + ] + + renderModal({ panels: singlePanel }) + + expect(screen.getByTestId('only-panel')).toBeInTheDocument() + expect(screen.getByTestId('menu-item-only')).toBeInTheDocument() + }) + + it('should handle panel with undefined key gracefully', () => { + const panelsWithUndefined = [ + { + key: 'valid', + label: 'Valid Panel', + panel:
Valid Panel
+ } + ] + + renderModal({ panels: panelsWithUndefined }) + + expect(screen.getByTestId('valid-panel')).toBeInTheDocument() + }) + }) +}) diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/AdvancedSettingsPanel.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/AdvancedSettingsPanel.test.tsx.snap new file mode 100644 index 0000000000..3563d77f88 --- /dev/null +++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/AdvancedSettingsPanel.test.tsx.snap @@ -0,0 +1,329 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AdvancedSettingsPanel > basic rendering > should match snapshot 1`] = ` +.c0 { + padding: 0 16px; +} + +.c1 { + margin-bottom: 24px; +} + +.c1 .settings-label { + font-size: 14px; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +
+
+
+ 分块大小 +
+ knowledge.chunk_size_tooltip +
+
+
+
+ + + + + + + + + + +
+
+ +
+
+
+
+
+ 分块重叠 +
+ knowledge.chunk_overlap_tooltip +
+
+
+
+ + + + + + + + + + +
+
+ +
+
+
+
+
+ 检索相似度阈值 +
+ knowledge.threshold_tooltip +
+
+
+
+ + + + + + + + + + +
+
+ +
+
+
+ +
+`; diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap new file mode 100644 index 0000000000..d9bc68ed19 --- /dev/null +++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap @@ -0,0 +1,201 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = ` +.c0 { + padding: 0 16px; +} + +.c1 { + margin-bottom: 24px; +} + +.c1 .settings-label { + font-size: 14px; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +
+
+
+ common.name +
+ +
+
+
+ settings.tool.preprocess.title + / + settings.tool.ocr.title + + ℹ️ + +
+ +
+
+
+ models.embedding_model + + ℹ️ + +
+ +
+
+
+ knowledge.dimensions + + ℹ️ + +
+ +
+
+
+ models.rerank_model + + ℹ️ + +
+ +
+
+
+ knowledge.document_count + + ℹ️ + +
+ +
+
+`; diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap new file mode 100644 index 0000000000..1273235f18 --- /dev/null +++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap @@ -0,0 +1,141 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`KnowledgeBaseFormModal > basic rendering > should match snapshot 1`] = ` +.c0 .ant-modal-title { + font-size: 14px; +} + +.c0 .ant-modal-close { + top: 4px; + right: 4px; +} + +.c1 { + display: flex; + height: 100%; + border-right: 0.5px solid var(--color-border); +} + +.c3 { + flex: 1; + padding: 16px 16px; + overflow-y: scroll; +} + +.c2 { + width: 200px; + padding: 5px; + background: transparent; + margin-top: 2px; + border-inline-end: none!important; +} + +.c2 .ant-menu-item { + height: 36px; + color: var(--color-text-2); + display: flex; + align-items: center; + border: 0.5px solid transparent; + border-radius: 6px; + margin-bottom: 7px; +} + +.c2 .ant-menu-item .ant-menu-title-content { + line-height: 36px; +} + +.c2 .ant-menu-item-active { + background-color: var(--color-background-soft)!important; + transition: none; +} + +.c2 .ant-menu-item-selected { + background-color: var(--color-background-soft); + border: 0.5px solid var(--color-border); +} + +.c2 .ant-menu-item-selected .ant-menu-title-content { + color: var(--color-text-1); + font-weight: 500; +} + +
+
+ + Knowledge Base Settings + + +
+
+
+
+
+
+ General Settings +
+
+ Advanced Settings +
+
+
+
+
+ General Settings Panel +
+
+
+
+
+ + +
+
+`; diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgeBasePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgeBasePopup.tsx new file mode 100644 index 0000000000..ccc846b706 --- /dev/null +++ b/src/renderer/src/pages/knowledge/components/AddKnowledgeBasePopup.tsx @@ -0,0 +1,117 @@ +import { loggerService } from '@logger' +import { TopView } from '@renderer/components/TopView' +import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { useKnowledgeBaseForm } from '@renderer/hooks/useKnowledgeBaseForm' +import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' +import { formatErrorMessage } from '@renderer/utils/error' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { + AdvancedSettingsPanel, + GeneralSettingsPanel, + KnowledgeBaseFormModal, + type PanelConfig +} from './KnowledgeSettings' + +const logger = loggerService.withContext('AddKnowledgeBasePopup') + +interface ShowParams { + title: string +} + +interface PopupContainerProps extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ title, resolve }) => { + const [open, setOpen] = useState(true) + const { t } = useTranslation() + const { addKnowledgeBase } = useKnowledgeBases() + const { + newBase, + setNewBase, + handlers, + providerData: { selectedDocPreprocessProvider, docPreprocessSelectOptions } + } = useKnowledgeBaseForm() + + const onOk = async () => { + if (!newBase.name?.trim()) { + window.message.error(t('knowledge.name_required')) + return + } + + if (!newBase.model) { + window.message.error(t('knowledge.embedding_model_required')) + return + } + + try { + const _newBase = { + ...newBase, + created_at: Date.now(), + updated_at: Date.now() + } + + await window.api.knowledgeBase.create(getKnowledgeBaseParams(_newBase)) + + addKnowledgeBase(_newBase) + setOpen(false) + resolve(_newBase) + } catch (error) { + logger.error('KnowledgeBase creation failed:', error as Error) + window.message.error(t('knowledge.error.failed_to_create') + formatErrorMessage(error)) + } + } + + const onCancel = () => { + setOpen(false) + resolve(null) + } + + const panelConfigs: PanelConfig[] = [ + { + key: 'general', + label: t('settings.general.label'), + panel: ( + + ) + }, + { + key: 'advanced', + label: t('settings.advanced.title'), + panel: + } + ] + + return +} + +export default class AddKnowledgeBasePopup { + static TopViewKey = 'AddKnowledgeBasePopup' + + static hide() { + TopView.hide(this.TopViewKey) + } + + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + />, + this.TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx deleted file mode 100644 index c476e543d6..0000000000 --- a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx +++ /dev/null @@ -1,533 +0,0 @@ -import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons' -import { loggerService } from '@logger' -import AiProvider from '@renderer/aiCore' -import { HStack } from '@renderer/components/Layout' -import ModelSelector from '@renderer/components/ModelSelector' -import { TopView } from '@renderer/components/TopView' -import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, isMac } from '@renderer/config/constant' -import { getEmbeddingMaxContext } from '@renderer/config/embedings' -import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' -import { useOcrProviders } from '@renderer/hooks/useOcr' -import { usePreprocessProviders } from '@renderer/hooks/usePreprocess' -import { useProviders } from '@renderer/hooks/useProvider' -import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' -import { getModelUniqId } from '@renderer/services/ModelService' -import { KnowledgeBase, Model, OcrProvider, PreprocessProvider } from '@renderer/types' -import { getErrorMessage } from '@renderer/utils/error' -import { Alert, Input, InputNumber, Modal, Select, Slider, Switch, Tooltip } from 'antd' -import { find } from 'lodash' -import { ChevronDown } from 'lucide-react' -import { nanoid } from 'nanoid' -import { useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface ShowParams { - title: string -} - -interface Props extends ShowParams { - resolve: (data: any) => void -} - -const logger = loggerService.withContext('AddKnowledgePopup') - -const PopupContainer: React.FC = ({ title, resolve }) => { - const [open, setOpen] = useState(true) - const [loading, setLoading] = useState(false) - const [autoDims, setAutoDims] = useState(true) - const [showAdvanced, setShowAdvanced] = useState(false) - const { t } = useTranslation() - const { providers } = useProviders() - const { addKnowledgeBase } = useKnowledgeBases() - const [newBase, setNewBase] = useState({} as KnowledgeBase) - const [dimensions, setDimensions] = useState(undefined) - - const { preprocessProviders } = usePreprocessProviders() - const { ocrProviders } = useOcrProviders() - const [selectedProvider, setSelectedProvider] = useState(undefined) - - const embeddingModels = useMemo(() => { - return providers - .map((p) => p.models) - .flat() - .filter((model) => isEmbeddingModel(model)) - }, [providers]) - - const rerankModels = useMemo(() => { - return providers - .map((p) => p.models) - .flat() - .filter((model) => isRerankModel(model)) - }, [providers]) - - const nameInputRef = useRef(null) - const scrollContainerRef = useRef(null) - - const preprocessOrOcrSelectOptions = useMemo(() => { - const preprocessOptions = { - label: t('settings.tool.preprocess.provider'), - title: t('settings.tool.preprocess.provider'), - options: preprocessProviders - // todo: 免费期结束后删除 - .filter((p) => p.apiKey !== '' || p.id === 'mineru') - .map((p) => ({ value: p.id, label: p.name })) - } - const ocrOptions = { - label: t('settings.tool.ocr.provider'), - title: t('settings.tool.ocr.provider'), - options: ocrProviders.filter((p) => p.apiKey !== '').map((p) => ({ value: p.id, label: p.name })) - } - - return isMac ? [preprocessOptions, ocrOptions] : [preprocessOptions] - }, [ocrProviders, preprocessProviders, t]) - - const onOk = async () => { - try { - if (!newBase.name?.trim()) { - window.message.error(t('knowledge.name_required')) - return - } - if (!newBase.model) { - window.message.error(t('knowledge.embedding_model_required')) - return - } - // const values = await form.validateFields() - const selectedEmbeddingModel = find(embeddingModels, newBase.model) as Model - - const selectedRerankModel = newBase.rerankModel ? (find(rerankModels, newBase.rerankModel) as Model) : undefined - - if (selectedEmbeddingModel) { - setLoading(true) - const provider = providers.find((p) => p.id === selectedEmbeddingModel.provider) - - if (!provider) { - return - } - let finalDimensions: number // 用于存储最终确定的维度值 - - if (autoDims || dimensions === undefined) { - try { - const aiProvider = new AiProvider(provider) - finalDimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel) - - setDimensions(finalDimensions) - } catch (error) { - logger.error('Error getting embedding dimensions:', error as Error) - window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error)) - setLoading(false) - return - } - } else { - finalDimensions = dimensions - } - - const _newBase = { - ...newBase, - id: nanoid(), - name: newBase.name, - model: selectedEmbeddingModel, - rerankModel: selectedRerankModel, - dimensions: finalDimensions, - documentCount: newBase.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, - items: [], - created_at: Date.now(), - updated_at: Date.now(), - version: 1 - } - - await window.api.knowledgeBase.create(getKnowledgeBaseParams(_newBase)) - - addKnowledgeBase(_newBase as any) - setOpen(false) - resolve(_newBase) - } - } catch (error) { - logger.error('Validation failed:', error as Error) - } - } - const onCancel = () => { - setOpen(false) - } - - const onClose = () => { - resolve(null) - } - - useEffect(() => { - if (showAdvanced && scrollContainerRef.current) { - // 延迟滚动,确保DOM更新完成 - setTimeout(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - top: scrollContainerRef.current.scrollHeight, - behavior: 'smooth' - }) - } - }, 300) - } - }, [showAdvanced]) - - return ( - visible && nameInputRef.current?.focus()} - destroyOnClose - centered - transitionName="animation-move-down" - okButtonProps={{ loading }} - width="min(600px, 60vw)" - styles={{ - body: { padding: 0 }, - header: { - padding: '10px 15px', - borderBottom: '0.5px solid var(--color-border)', - margin: 0, - borderRadius: 0 - }, - content: { - padding: 0, - paddingBottom: 10, - overflow: 'hidden' - } - }}> - - - - -
{t('common.name')}
- { - if (e.target.value) { - setNewBase({ ...newBase, name: e.target.value }) - } - }} - /> -
- - -
- {t('settings.tool.preprocess.title')} / {t('settings.tool.ocr.title')} - - - -
- setNewBase({ ...newBase, name: e.target.value })} - /> -
- - -
- {t('settings.tool.preprocess.title')} / {t('settings.tool.ocr.title')} - - - -
- -
- - -
- {t('models.rerank_model')} - - - -
- { - const rerankModel = value - ? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value) - : undefined - setNewBase({ ...newBase, rerankModel }) - }} - allowClear - /> -
- - -
- {t('knowledge.document_count')} - - - -
- setNewBase({ ...newBase, documentCount: value })} - /> -
-
- ) - } - if (selectedMenu === 'advanced') { - return ( - - -
- {t('knowledge.chunk_size')} - - - -
- { - const maxContext = getEmbeddingMaxContext(base.model.id) - if (!value || !maxContext || value <= maxContext) { - setNewBase({ ...newBase, chunkSize: value || undefined }) - } - }} - /> -
- - -
- {t('knowledge.chunk_overlap')} - - - -
- { - if (!value || (newBase.chunkSize && newBase.chunkSize > value)) { - setNewBase({ ...newBase, chunkOverlap: value || undefined }) - } - await window.message.error(t('message.error.chunk_overlap_too_large')) - }} - /> -
- - -
- {t('knowledge.threshold')} - - - -
- setNewBase({ ...newBase, threshold: value || undefined })} - /> -
- - } - /> -
- ) - } - return null - } - - KnowledgeSettings.hide = onCancel - - return ( - - - - setSelectedMenu(key)} - /> - - {renderSettings()} - - - ) -} - -const TopViewKey = 'KnowledgeSettingsPopup' - -const SettingsPanel = styled.div` - padding: 0 16px; -` - -const SettingsItem = styled.div` - margin-bottom: 24px; - - .settings-label { - font-size: 14px; - margin-bottom: 8px; - display: flex; - align-items: center; - } -` -const SettingsModal = styled(Modal)` - .ant-modal-title { - font-size: 14px; - } - .ant-modal-close { - top: 4px; - right: 4px; - } - .ant-menu-item { - height: 36px; - color: var(--color-text-2); - display: flex; - align-items: center; - border: 0.5px solid transparent; - border-radius: 6px; - .ant-menu-title-content { - line-height: 36px; - } - } - .ant-menu-item-active { - background-color: var(--color-background-soft) !important; - transition: none; - } - .ant-menu-item-selected { - background-color: var(--color-background-soft); - border: 0.5px solid var(--color-border); - .ant-menu-title-content { - color: var(--color-text-1); - font-weight: 500; - } - } -` - -const LeftMenu = styled.div` - display: flex; - height: 100%; - border-right: 0.5px solid var(--color-border); -` - -const SettingsContentPanel = styled.div` - flex: 1; - padding: 16px 16px; - overflow-y: scroll; -` - -const StyledMenu = styled(Menu)` - width: 200px; - padding: 5px; - background: transparent; - margin-top: 2px; - border-inline-end: none !important; - .ant-menu-item { - margin-bottom: 7px; - } -` - -export default class KnowledgeSettings { - static hide() { - TopView.hide(TopViewKey) - } - - static show(props: ShowParams) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - TopView.hide(TopViewKey) - }} - />, - TopViewKey - ) - }) - } -} diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/AdvancedSettingsPanel.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/AdvancedSettingsPanel.tsx new file mode 100644 index 0000000000..ba7e04f1a9 --- /dev/null +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/AdvancedSettingsPanel.tsx @@ -0,0 +1,76 @@ +import { WarningOutlined } from '@ant-design/icons' +import InfoTooltip from '@renderer/components/InfoTooltip' +import { KnowledgeBase } from '@renderer/types' +import { Alert, InputNumber } from 'antd' +import { useTranslation } from 'react-i18next' + +import { SettingsItem, SettingsPanel } from './styles' + +interface AdvancedSettingsPanelProps { + newBase: KnowledgeBase + handlers: { + handleChunkSizeChange: (value: number | null) => void + handleChunkOverlapChange: (value: number | null) => void + handleThresholdChange: (value: number | null) => void + } +} + +const AdvancedSettingsPanel: React.FC = ({ newBase, handlers }) => { + const { t } = useTranslation() + const { handleChunkSizeChange, handleChunkOverlapChange, handleThresholdChange } = handlers + + return ( + + +
+ {t('knowledge.chunk_size')} + +
+ +
+ + +
+ {t('knowledge.chunk_overlap')} + +
+ +
+ + +
+ {t('knowledge.threshold')} + +
+ +
+ + } /> +
+ ) +} + +export default AdvancedSettingsPanel diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx new file mode 100644 index 0000000000..010ad8cb40 --- /dev/null +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx @@ -0,0 +1,128 @@ +import InfoTooltip from '@renderer/components/InfoTooltip' +import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension' +import ModelSelector from '@renderer/components/ModelSelector' +import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { useProviders } from '@renderer/hooks/useProvider' +import { getModelUniqId } from '@renderer/services/ModelService' +import { KnowledgeBase, PreprocessProvider } from '@renderer/types' +import { Input, Select, Slider } from 'antd' +import { useTranslation } from 'react-i18next' + +import { SettingsItem, SettingsPanel } from './styles' + +interface GeneralSettingsPanelProps { + newBase: KnowledgeBase + setNewBase: React.Dispatch> + selectedDocPreprocessProvider?: PreprocessProvider + docPreprocessSelectOptions: any[] + handlers: { + handleEmbeddingModelChange: (value: string) => void + handleDimensionChange: (value: number | null) => void + handleRerankModelChange: (value: string) => void + handleDocPreprocessChange: (value: string) => void + } +} + +const GeneralSettingsPanel: React.FC = ({ + newBase, + setNewBase, + selectedDocPreprocessProvider, + docPreprocessSelectOptions, + handlers +}) => { + const { t } = useTranslation() + const { providers } = useProviders() + const { handleEmbeddingModelChange, handleDimensionChange, handleRerankModelChange, handleDocPreprocessChange } = + handlers + + return ( + + +
{t('common.name')}
+ setNewBase((prev) => ({ ...prev, name: e.target.value }))} + /> +
+ + +
+ {t('settings.tool.preprocess.title')} / {t('settings.tool.ocr.title')} + +
+