From 5177b47aeb23f17f09644664ad50952f67b56c6c Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 27 Sep 2026 10:05:50 +0800 Subject: [PATCH] fix: unify knowledge documents and sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge single-document sources into one row, keep grouped directory documents and failed sources visible, and share search and task actions. Compact the drop zone and move secondary actions into a disclosure. Include the existing knowledge workspace component extraction required by this implementation, focused regression tests, bilingual copy, and design documentation. Validation: 94 focused tests, typecheck, lint, and diff checks passed. Full tests timed out after 200 seconds; two unrelated HeartbeatCenter tablist failures were reproduced separately. Electron visual acceptance remains unverified. Release note: 知识库的内容来源与文档索引合并为统一列表,减少重复记录和操作;支持统一搜索,并保留目录分组和失败来源的恢复入口。 --- docs/features/knowledge-base/progress.md | 19 + docs/features/knowledge-base/ui-design.md | 19 + src/renderer/src/KnowledgeWorkspace.test.tsx | 181 +- src/renderer/src/KnowledgeWorkspace.tsx | 4308 +---------------- .../src/i18n/locales/en-US/knowledge.ts | 18 +- .../src/i18n/locales/zh-CN/knowledge.ts | 18 +- .../src/knowledge-workspace/DocumentsView.tsx | 713 +++ .../GraphDestructiveDialog.tsx | 149 + .../src/knowledge-workspace/GraphEditors.tsx | 272 ++ .../src/knowledge-workspace/GraphView.tsx | 859 ++++ .../KnowledgeGraphChartLoader.tsx | 99 + .../KnowledgeSettingsView.tsx | 860 ++++ .../KnowledgeTasksView.tsx | 489 ++ .../knowledge-workspace/LibraryDialogs.tsx | 436 ++ .../RemoveSourceDialog.tsx | 107 + .../src/knowledge-workspace/formatting.ts | 51 + .../src/knowledge-workspace/helpers.ts | 45 + src/renderer/src/knowledge-workspace/types.ts | 198 + src/renderer/src/styles.css | 80 +- 19 files changed, 4613 insertions(+), 4308 deletions(-) create mode 100644 src/renderer/src/knowledge-workspace/DocumentsView.tsx create mode 100644 src/renderer/src/knowledge-workspace/GraphDestructiveDialog.tsx create mode 100644 src/renderer/src/knowledge-workspace/GraphEditors.tsx create mode 100644 src/renderer/src/knowledge-workspace/GraphView.tsx create mode 100644 src/renderer/src/knowledge-workspace/KnowledgeGraphChartLoader.tsx create mode 100644 src/renderer/src/knowledge-workspace/KnowledgeSettingsView.tsx create mode 100644 src/renderer/src/knowledge-workspace/KnowledgeTasksView.tsx create mode 100644 src/renderer/src/knowledge-workspace/LibraryDialogs.tsx create mode 100644 src/renderer/src/knowledge-workspace/RemoveSourceDialog.tsx create mode 100644 src/renderer/src/knowledge-workspace/formatting.ts create mode 100644 src/renderer/src/knowledge-workspace/helpers.ts create mode 100644 src/renderer/src/knowledge-workspace/types.ts diff --git a/docs/features/knowledge-base/progress.md b/docs/features/knowledge-base/progress.md index 99d5a9c4..f331c7d1 100644 --- a/docs/features/knowledge-base/progress.md +++ b/docs/features/knowledge-base/progress.md @@ -16,6 +16,25 @@ ## 验证证据 +### 2026-09-27 文档与来源统一列表 + +在现有 `knowledge-workspace` 拆分基础上修改 `DocumentsView.tsx`,单文件、单 URL 与 +唯一关联文档合成一行;目录及一对多来源保留组标题,零文档来源和孤立文档继续可见。 +统一搜索、完整移除数量、次要操作折叠及预览返回规则见 [UI 设计](./ui-design.md#6-来源操作)。 +`KnowledgeTasksView.tsx` 支持合并行同时传入来源和文档上下文,展示两者相关任务。 + +- `npx vitest run src/renderer/src/KnowledgeWorkspace.test.tsx src/renderer/src/DocumentResultPreview.test.tsx src/renderer/src/KnowledgeChunkManager.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx`:4 个文件、94 项通过。 +- 新增用例覆盖单文件与 URL 去重、来源和文档搜索、一对多过滤、零文档来源重试与暂停、 + 孤立文档、完整移除数量、合并任务入口、拖放,以及预览返回后的搜索、焦点和滚动恢复。 + 工作区测试使用解析预览替身;真实预览组件由同次运行的 `DocumentResultPreview.test.tsx` 验证。 +- `npm run typecheck`、`npm run lint` 和 `git diff --check` 通过。 +- 完整 `npm test` 运行 200 秒后超时,超时前输出 `HeartbeatCenter.test.tsx` 两项失败, + 全量测试未完成。独立运行 `npm test -- src/renderer/src/HeartbeatCenter.test.tsx` + 复现 40 项通过、2 项失败:zh-CN/en-US 的 `keeps tab titles unique...` 用例在第 213 行 + 期望 1 个 `tablist`,实际为 2 个。该失败与本次知识库修改无关,本轮未修改心跳中心。 +- 未做真实 Electron 浅深主题、窄窗口、原生键盘及 UI → Main 导入验收。 + 未调用外部模型或知识服务,未提交改动。 + ### 2026-09-20 清理孤立的内存图谱搜索 - 删除仅剩测试调用的 `searchGraph`、专属评分/遍历辅助函数、搜索上限及测试 fixture。 diff --git a/docs/features/knowledge-base/ui-design.md b/docs/features/knowledge-base/ui-design.md index 8a3284e1..75c2f23c 100644 --- a/docs/features/knowledge-base/ui-design.md +++ b/docs/features/knowledge-base/ui-design.md @@ -64,6 +64,25 @@ Conversation 的检索范围,返回对话并聚焦输入框。 ## 6. 来源操作 +“文档与来源”使用一张按来源组织的表格。单文件、单 URL 来源只有一篇关联文档时, +来源与文档合成一行,以文档名为主标题,同步状态、同步时间和索引状态就近显示。 +不再单独展示同名来源行,也不重复任务入口。目录及其他一对多来源保留来源组标题, +文档紧随其后;零文档和失败来源仍保留同步、暂停、重试及移除入口。 +没有 `sourceId` 或找不到对应来源的文档独立显示。 + +搜索同时匹配来源名称、来源位置、文档名称和路径。来源命中时显示该组全部文档; +仅文档命中时保留组标题和命中文档。一对多关系按完整数据判断,搜索不能将其临时合成 +单文档行。移除确认使用来源的完整文档数量,不使用搜索结果数。 + +导入文件、目录、URL 及拖放入口保持可用,拖放区使用紧凑的图标与文字提示。 +单文档行的同步、移除和重新解析收进原生“更多操作”折叠区,解析预览、打开来源、 +分块和任务保留直接入口。失败文档的重新处理入口仍直接显示。合并行的任务入口包含 +该来源及该文档的相关任务,保留没有来源 ID 的文档任务。大小与分块数不拆行,操作组 +按可用宽度换行,窄窗口在表格内部横向滚动。 + +解析预览复用现有结果组件;返回列表时恢复搜索、原触发按钮焦点及页面滚动位置。 +重新解析继续调用文档重建,使用当前设置并保留上次结果,失败时显示可恢复错误。 + 文档行提供“打开来源”,Main 根据知识库和文档归属重新读取保存的文件路径或 URL。 移除来源使用确认对话框,说明文档数、索引、图谱证据和托管副本影响。初始焦点位于 取消,提交期间禁止重复操作,失败时保留对话框和错误。 diff --git a/src/renderer/src/KnowledgeWorkspace.test.tsx b/src/renderer/src/KnowledgeWorkspace.test.tsx index 6943601d..a81c5529 100644 --- a/src/renderer/src/KnowledgeWorkspace.test.tsx +++ b/src/renderer/src/KnowledgeWorkspace.test.tsx @@ -7,29 +7,26 @@ import { waitFor, within } from '@testing-library/react' -import { readFileSync } from 'node:fs' +import { readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { - KnowledgeGraphChartLoader, - KnowledgeWorkspace, - type KnowledgeWorkspaceProps -} from './KnowledgeWorkspace' +import { KnowledgeWorkspace } from './KnowledgeWorkspace' +import { PageShell } from './WorkspacePrimitives' +import { KnowledgeGraphChartLoader } from './knowledge-workspace/KnowledgeGraphChartLoader' +import type { KnowledgeWorkspaceProps } from './knowledge-workspace/types' import i18n from './i18n' import { defaultKnowledgeOntologySettings } from '../../shared/knowledge-ontology' -const knowledgeWorkspaceSource = readFileSync( - join( - process.cwd(), - 'src', - 'renderer', - 'src', - 'KnowledgeWorkspace.tsx' - ), - 'utf8' -) +const rendererDirectory = join(process.cwd(), 'src', 'renderer', 'src') +const knowledgeWorkspaceDirectory = join(rendererDirectory, 'knowledge-workspace') +const knowledgeWorkspaceSource = [ + join(rendererDirectory, 'KnowledgeWorkspace.tsx'), + ...readdirSync(knowledgeWorkspaceDirectory) + .filter((name) => name.endsWith('.tsx')) + .map((name) => join(knowledgeWorkspaceDirectory, name)) +].map((file) => readFileSync(file, 'utf8')).join('\n') const g6Mock = vi.hoisted(() => { const handlers = new Map void>() @@ -73,6 +70,10 @@ vi.mock('@antv/g6', () => ({ } })) +vi.mock('./DocumentResultPreview', () => ({ + DocumentResultPreview: ({ resultId }: { resultId: string }) =>
Preview {resultId}
+})) + const library: KnowledgeWorkspaceProps['libraries'][number] = { id: 'library-1', name: '产品知识', @@ -262,6 +263,150 @@ function createProps( } describe('KnowledgeWorkspace', () => { + it.each(['file', 'url'] as const)('merges a single %s source and document, and searches both identities', async (kind) => { + const base = createProps() + const source = { ...base.sources[0]!, kind, name: 'source-alias', location: 'https://example.com/source-location' } + const document = { ...base.documents[0]!, name: 'manual.pdf', path: '/docs/document-location.pdf', resultId: 'result-1' } + const props = createProps({ sources: [source], documents: [document] }) + const view = render() + const table = screen.getByRole('table', { name: '文档与来源' }) + expect(within(table).getAllByRole('row')).toHaveLength(2) + expect(within(table).getAllByText('manual.pdf')).toHaveLength(1) + expect(within(table).queryByText('source-alias')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: '内容来源' })).not.toBeInTheDocument() + const search = screen.getByRole('searchbox', { name: '搜索文档与来源' }) + for (const value of ['source-alias', 'source-location', 'manual.pdf', 'document-location']) { + fireEvent.change(search, { target: { value } }) + expect(within(table).getAllByRole('row')).toHaveLength(2) + } + fireEvent.change(search, { target: { value: 'no-match' } }) + expect(screen.getByText('没有匹配的文档或来源,请尝试其他名称或路径。')).toBeVisible() + fireEvent.change(search, { target: { value: '' } }) + view.rerender() + expect(screen.getAllByText('manual.pdf')).toHaveLength(1) + fireEvent.click(screen.getByText('更多操作')) + fireEvent.click(screen.getByRole('button', { name: '同步 manual.pdf' })) + await waitFor(() => expect(props.onSyncSource).toHaveBeenCalledWith(source.id)) + fireEvent.click(screen.getByRole('button', { name: '使用当前设置重新解析' })) + await waitFor(() => expect(props.onRebuildDocument).toHaveBeenCalledWith(library.id, document.id)) + for (const [status, name, callback] of [ + ['syncing', '暂停', props.onPauseSource], + ['failed', '重试', props.onRetrySource], + ['paused', '同步', props.onSyncSource] + ] as const) { + view.rerender() + fireEvent.click(screen.getByRole('button', { name: `${name} manual.pdf` })) + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(source.id) + expect(screen.getByRole('button', { name: `${name} manual.pdf` })).toBeEnabled() + }) + } + }) + + it('keeps a multi-document URL grouped when search leaves just one matching document', () => { + const base = createProps() + render() + fireEvent.change(screen.getByRole('searchbox', { name: '搜索文档与来源' }), { target: { value: 'second.md' } }) + const table = screen.getByRole('table', { name: '文档与来源' }) + expect(within(table).getAllByRole('row')).toHaveLength(3) + expect(within(table).getByText('Website')).toBeVisible() + expect(within(table).getByText('second.md')).toBeVisible() + expect(within(table).queryByText('架构说明.md')).not.toBeInTheDocument() + }) + + it('keeps directory groups, empty failed sources and orphan documents, with unfiltered removal counts', async () => { + const base = createProps() + const props = createProps({ + sources: [ + { ...base.sources[0]!, documentCount: 2 }, + { ...base.sources[0]!, id: 'empty', name: 'failed.pdf', location: '/failed.pdf', kind: 'file', status: 'failed', documentCount: 0, error: '读取失败' }, + { ...base.sources[0]!, id: 'syncing', name: 'loading.pdf', location: '/loading.pdf', kind: 'file', status: 'syncing', documentCount: 0 } + ], + documents: [ + base.documents[0]!, + { ...base.documents[0]!, id: 'second', name: 'second.md' }, + { ...base.documents[0]!, id: 'orphan', sourceId: 'missing', name: 'orphan.md' }, + { ...base.documents[0]!, id: 'unassigned', sourceId: undefined, name: 'unassigned.md' } + ] + }) + render() + const table = screen.getByRole('table', { name: '文档与来源' }) + expect(within(table).getAllByRole('row')).toHaveLength(8) + expect(within(table).getByText('orphan.md')).toBeVisible() + expect(within(table).getByText('unassigned.md')).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: '重试 failed.pdf' })) + await waitFor(() => expect(props.onRetrySource).toHaveBeenCalledWith('empty')) + fireEvent.click(screen.getByRole('button', { name: '暂停 loading.pdf' })) + await waitFor(() => expect(props.onPauseSource).toHaveBeenCalledWith('syncing')) + const search = screen.getByRole('searchbox', { name: '搜索文档与来源' }) + fireEvent.change(search, { target: { value: '产品手册' } }) + expect(within(table).getAllByRole('row')).toHaveLength(4) + fireEvent.change(search, { target: { value: 'second.md' } }) + expect(within(table).getAllByRole('row')).toHaveLength(3) + expect(within(table).getByText('产品手册')).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: '移除来源 产品手册' })) + expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription( + '将删除此来源的 2 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。' + ) + fireEvent.click(screen.getByRole('button', { name: '移除来源' })) + await waitFor(() => expect(props.onRemoveSource).toHaveBeenCalledWith('source-1')) + }) + + it('restores the merged preview trigger, search and scroll position, and retains file drop imports', async () => { + const base = createProps() + const props = createProps({ + sources: [{ ...base.sources[0]!, kind: 'file' }], + documents: [{ ...base.documents[0]!, resultId: 'result-1' }] + }) + render() + const search = screen.getByRole('searchbox', { name: '搜索文档与来源' }) + fireEvent.change(search, { target: { value: '架构' } }) + const trigger = screen.getByRole('button', { name: '查看解析结果' }) + const scroll = trigger.closest('.workspace-panel-scroll')! + scroll.scrollTop = 240 + trigger.focus() + fireEvent.click(trigger) + await waitFor(() => { + expect(screen.getByRole('button', { name: '返回文档列表' })).toHaveFocus() + expect(scroll.scrollTop).toBe(0) + expect(screen.getByText('Preview result-1')).toBeVisible() + }) + fireEvent.click(screen.getByRole('button', { name: '返回文档列表' })) + await waitFor(() => { + expect(trigger).toHaveFocus() + expect(scroll.scrollTop).toBe(240) + expect(search).toHaveValue('架构') + }) + const file = new File(['text'], 'dropped.txt', { type: 'text/plain' }) + fireEvent.drop(screen.getByText('将文件拖到这里,加入“产品知识”'), { dataTransfer: { files: [file] } }) + await waitFor(() => expect(props.onImportFiles).toHaveBeenCalledWith(library.id, [file], undefined)) + }) + + it('opens both source and document tasks from one merged task action', () => { + const base = createProps() + const task = { + libraryId: library.id, kind: 'document-process' as const, stage: 'parsing' as const, + status: 'running' as const, progress: 20, attempt: 1, canCancel: false, canRetry: false, + createdAt: '2026-09-27T00:00:00Z', updatedAt: '2026-09-27T00:00:00Z' + } + render() + expect(screen.getAllByRole('button', { name: '查看任务' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: '查看任务' })) + expect(screen.getByText('Source task')).toBeVisible() + expect(screen.getByText('Document task')).toBeVisible() + expect(screen.queryByText('Unrelated task')).not.toBeInTheDocument() + }) + it('searches external names and instances and filters the unified library list', async () => { await i18n.changeLanguage('en-US') const external = { ...library, id: 'external-1', name: 'Company handbook', external: { knowledgeBaseId: 'external-1', instanceId: 'instance-1', provider: 'dify' as const, remoteKnowledgeBaseId: 'remote-1', remoteName: 'Remote policies', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: { provider: 'dify' as const, useDatasetDefaults: true as const }, lastVerifiedAt: '2026-09-12' } } @@ -1484,10 +1629,10 @@ describe('KnowledgeWorkspace', () => { expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass( 'page-tabs' ) - expect(screen.getByLabelText('搜索文档').closest('label')).toHaveClass( + expect(screen.getByLabelText('搜索文档与来源').closest('label')).toHaveClass( 'knowledge-documents__search' ) - expect(screen.getByLabelText('搜索文档')).not.toHaveStyle({ + expect(screen.getByLabelText('搜索文档与来源')).not.toHaveStyle({ outline: 'none' }) expect(screen.getByText('本地文件 · 架构说明.md')).toBeInTheDocument() diff --git a/src/renderer/src/KnowledgeWorkspace.tsx b/src/renderer/src/KnowledgeWorkspace.tsx index 4e008745..4204a96c 100644 --- a/src/renderer/src/KnowledgeWorkspace.tsx +++ b/src/renderer/src/KnowledgeWorkspace.tsx @@ -1,449 +1,67 @@ -import { DocumentResultPreview } from './DocumentResultPreview' -import { - AlertCircle, - ArrowLeft, - ArrowRight, - BookOpen, - Check, - ChevronDown, - ChevronRight, - CirclePause, - Database, - FilePlus2, - FileText, - FolderOpen, - GitMerge, - Link2, - ListChecks, - LoaderCircle, - MessageSquare, - Network, - Pencil, - Plus, - RefreshCw, - RotateCcw, - Search, - Settings2, - Trash2, - UploadCloud, - X, - ZoomIn, - ZoomOut -} from 'lucide-react' -import { - Component, - Suspense, - type ReactNode, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState -} from 'react' -import { useTranslation } from 'react-i18next' -import { createPortal } from 'react-dom' -import { InlineHelp } from './InlineHelp' -import type { TFunction } from 'i18next' import type { - KnowledgeDocumentItem as SharedKnowledgeDocumentItem, - KnowledgeEvidence as SharedKnowledgeEvidence, - KnowledgeGraphNode as SharedKnowledgeGraphNode, - KnowledgeGraphRelation as SharedKnowledgeGraphRelation, - KnowledgeLibrary as SharedKnowledgeLibrary, - KnowledgeSourceItem as SharedKnowledgeSource, - KnowledgeTaskItem as SharedKnowledgeTaskItem -} from '../../shared/contracts' -import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text' -import type { - KnowledgeEmbeddingIndexSnapshot -} from '../../shared/embedding-contracts' + KnowledgeStorageMode, + KnowledgeWorkspaceProps, + KnowledgeLibrary, + KnowledgeDocumentItem +} from './knowledge-workspace/types' import { - defaultKnowledgeChunkingSettings, - defaultKnowledgeRetrievalSettings, - type KnowledgeChunkPage, - type KnowledgeChunkUpdateInput, - type KnowledgeChunkingSettings, type KnowledgeRetrievalResponse, - type KnowledgeRetrievalSettings + type KnowledgeChunkPage, + defaultKnowledgeRetrievalSettings } from '../../shared/knowledge-contracts' import { - defaultKnowledgeOntologySettings, - getKnowledgeOntologyDisplayDefinitions, - isRelationEndpointAllowed, - knowledgeOntologySettingsSchema, - normalizeEntityTypeAlias, - normalizeRelationTypeAlias, - type KnowledgeOntologySettings -} from '../../shared/knowledge-ontology' -import { - EmptyState, - PageHeader, - PageTabs, - ScopeBadge, - SegmentedControl, - type PageTab -} from './WorkspacePrimitives' -import { createPreloadableComponent } from './preloadable-component' -import type { - KnowledgeGraphChartProps -} from './KnowledgeGraphChart' -import { - KnowledgeChunkManager -} from './KnowledgeChunkManager' -import { - KnowledgeRetrievalWorkbench, type KnowledgeRetrievalWorkbenchResponse, + KnowledgeRetrievalWorkbench, type KnowledgeRetrievalWorkbenchSettings } from './KnowledgeRetrievalWorkbench' -import { activateModalFocus, trapTabFocus } from './dialog-focus' -import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection' -import { ExternalBindingForm, ExternalInstanceManager, ExternalLibraryDetail, externalProviderNames, externalLibraryStatus } from './ExternalKnowledge' -import type { ExternalKnowledgeInstanceSummary, ExternalKnowledgeProvider } from '../../shared/external-knowledge-contracts' -import type { KnowledgeSnapshot } from '../../shared/contracts' -import type { AppNotificationInput } from './notifications' - -type KnowledgeGraphChartModule = typeof import('./KnowledgeGraphChart') - -type KnowledgeGraphChartModuleLoader = ( -) => Promise - -function createKnowledgeGraphChartRoute( - loadModule: KnowledgeGraphChartModuleLoader -) { - return createPreloadableComponent( - loadModule, - (module) => module.KnowledgeGraphChart - ) -} - -const loadKnowledgeGraphChartModule: KnowledgeGraphChartModuleLoader = - () => import('./KnowledgeGraphChart') - -class KnowledgeGraphChunkErrorBoundary extends Component< - { - children: ReactNode - onRetry: () => void - retryLabel: string - errorMessage: string - }, - { failed: boolean } -> { - state = { failed: false } - - static getDerivedStateFromError(): { failed: boolean } { - return { failed: true } - } - - render(): ReactNode { - if (!this.state.failed) { - return this.props.children - } - return ( -
-
- ) - } -} - -export function KnowledgeGraphChartLoader({ - loadModule = loadKnowledgeGraphChartModule, - ...props -}: KnowledgeGraphChartProps & { - loadModule?: KnowledgeGraphChartModuleLoader -} -): React.JSX.Element { - const { t } = useTranslation('knowledge') - const [loaderState, setLoaderState] = useState(() => ({ - generation: 0, - route: createKnowledgeGraphChartRoute(loadModule) - })) - const route = loaderState.route - const Chart = route.Component - - return ( - - setLoaderState((current) => ({ - generation: current.generation + 1, - route: createKnowledgeGraphChartRoute(loadModule) - })) - } - retryLabel={t('actions.retry')} - > - - - - ) -} - -export type KnowledgeLibrary = SharedKnowledgeLibrary -export type KnowledgeStorageMode = KnowledgeLibrary['storageMode'] -export type KnowledgeGraphStrategy = KnowledgeLibrary['graphStrategy'] -export type KnowledgeSource = SharedKnowledgeSource -export type KnowledgeSourceKind = KnowledgeSource['kind'] -export type KnowledgeSourceStatus = KnowledgeSource['status'] -export type KnowledgeDocumentItem = SharedKnowledgeDocumentItem -export type KnowledgeDocumentStatus = KnowledgeDocumentItem['status'] - -export type CreateKnowledgeLibraryInput = { - name: string - description: string - storageMode: KnowledgeStorageMode - graphEnabled: boolean - graphStrategy: KnowledgeGraphStrategy -} - -export type KnowledgeGraphNode = SharedKnowledgeGraphNode -export type KnowledgeGraphRelation = SharedKnowledgeGraphRelation -export type KnowledgeEvidence = SharedKnowledgeEvidence -export type KnowledgeTaskItem = SharedKnowledgeTaskItem - -export type KnowledgeEntityUpdate = { - label: string - type: string - description: string - aliases: string[] -} - -export type KnowledgeRelationInput = { - sourceId: string - targetId: string - type: string - description: string -} - -export type KnowledgeWorkspaceProps = { - externalInstances?: ExternalKnowledgeInstanceSummary[] - onExternalChanged?: (snapshot?: KnowledgeSnapshot, createdId?: string) => void | Promise - notify?: (input: AppNotificationInput) => void - libraries: readonly KnowledgeLibrary[] - selectedLibraryId?: string - sources: readonly KnowledgeSource[] - documents: readonly KnowledgeDocumentItem[] - graphNodes: readonly KnowledgeGraphNode[] - graphRelations: readonly KnowledgeGraphRelation[] - evidence: readonly KnowledgeEvidence[] - tasks?: readonly KnowledgeTaskItem[] - loading?: boolean - loadError?: string - onRetryLoad: () => void | Promise - onSelectLibrary: (libraryId: string) => void - onCreateLibrary: ( - input: CreateKnowledgeLibraryInput - ) => void | Promise - onDeleteLibrary: (libraryId: string) => void | Promise - onUpdateLibrary: ( - libraryId: string, - update: { - name?: string - description?: string - graphEnabled?: boolean - graphStrategy?: KnowledgeGraphStrategy - } - ) => void | Promise - onReextractGraph: (libraryId: string) => void | Promise - onImportFiles: ( - libraryId: string, - files: File[], - graphStrategy?: Exclude - ) => void | Promise - onImportDirectory: ( - libraryId: string, - files: File[], - graphStrategy?: Exclude - ) => void | Promise - onImportUrl: ( - libraryId: string, - url: string, - graphStrategy?: Exclude - ) => void | Promise - onSyncSource: (sourceId: string) => void | Promise - onPauseSource: (sourceId: string) => void | Promise - onRetrySource: (sourceId: string) => void | Promise - onRemoveSource: (sourceId: string) => void | Promise - onRetrieve: ( - libraryId: string, - query: string, - settings: KnowledgeRetrievalSettings - ) => Promise - onUpdateKnowledgeSettings: ( - libraryId: string, - settings: { - retrieval?: KnowledgeRetrievalSettings - chunking?: KnowledgeChunkingSettings - ontology?: KnowledgeOntologySettings - } - ) => void | Promise - onListChunks: (input: { - libraryId: string - documentId: string - page: number - pageSize: number - search?: string - }) => Promise - onUpdateChunk: ( - input: KnowledgeChunkUpdateInput - ) => void | Promise - onDeleteChunk: (input: { - knowledgeBaseId: string - documentId: string - chunkId: string - }) => void | Promise - onRebuildDocument: ( - libraryId: string, - documentId: string - ) => void | Promise - onOpenDocumentSource: ( - libraryId: string, - documentId: string - ) => void | Promise - onRebuildLibrary: (libraryId: string) => void | Promise - onCancelRebuild: (libraryId: string) => void | Promise - onGetEmbeddingIndex: ( - libraryId: string - ) => - | KnowledgeEmbeddingIndexSnapshot - | Promise - onRebuildEmbeddingIndex: ( - libraryId: string - ) => Promise - onCancelTask: (taskId: string) => void | Promise - onRetryTask: (taskId: string) => void | Promise - onOpenReferenceSource: (input: { - knowledgeBaseId: string - documentId: string - chunkId: string - }) => void | Promise - onUseInChat: (libraryId: string) => void - onOpenModelSettings: () => void - onMoveNode: ( - nodeId: string, - position: { x: number; y: number } - ) => void - onCreateEntity: ( - input: KnowledgeEntityUpdate - ) => void | Promise - onUpdateEntity: ( - nodeId: string, - update: KnowledgeEntityUpdate - ) => void | Promise - onDeleteEntity: (nodeId: string) => void | Promise - onMergeEntities: ( - sourceNodeId: string, - targetNodeId: string - ) => void | Promise - onCreateRelation: ( - relation: KnowledgeRelationInput - ) => void | Promise - onUpdateRelation: ( - relationId: string, - relation: KnowledgeRelationInput - ) => void | Promise - onDeleteRelation: (relationId: string) => void | Promise - onOpenEvidence?: (evidence: KnowledgeEvidence) => void -} +import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text' +import { useTranslation } from 'react-i18next' +import { resolvedLocale, formatNumber } from './knowledge-workspace/formatting' +import { useState, useRef, useEffect } from 'react' +import type { ExternalKnowledgeProvider } from '../../shared/external-knowledge-contracts' +import { type KnowledgeTaskContext, KnowledgeTasksView } from './knowledge-workspace/KnowledgeTasksView' +import { toErrorMessage, strategyLabelKeys } from './knowledge-workspace/helpers' +import { type PageTab, PageHeader, EmptyState, SegmentedControl, PageTabs } from './WorkspacePrimitives' +import { + FileText, + Network, + ListChecks, + Settings2, + Database, + Plus, + BookOpen, + RefreshCw, + ArrowLeft, + LoaderCircle, + AlertCircle, + MessageSquare, + Search, + Pencil, + Trash2 +} from 'lucide-react' +import { + ExternalInstanceManager, + externalProviderNames, + externalLibraryStatus, + ExternalBindingForm, + ExternalLibraryDetail +} from './ExternalKnowledge' +import { CreateLibraryWizard, EditLibraryDialog, DeleteLibraryDialog } from './knowledge-workspace/LibraryDialogs' +import { DocumentsView } from './knowledge-workspace/DocumentsView' +import { GraphView } from './knowledge-workspace/GraphView' +import { defaultKnowledgeOntologySettings } from '../../shared/knowledge-ontology' +import { KnowledgeSettingsView } from './knowledge-workspace/KnowledgeSettingsView' +import { KnowledgeChunkManager } from './KnowledgeChunkManager' type WorkspaceTab = 'documents' | 'graph' | 'tasks' | 'settings' + type GraphWorkspaceTab = 'explore' | 'settings' -type GraphSidebarTab = 'topology' | 'details' const storageModeLabelKeys = { reference: 'storageModes.reference.label', managed: 'storageModes.managed.label' } as const satisfies Record -const strategyLabelKeys = { - rules: 'strategies.rules', - model: 'strategies.model', - hybrid: 'strategies.hybrid', - ask: 'strategies.ask' -} as const satisfies Record - -function parseAliases(value: string, limit?: number): string[] { - const aliases = value - .split(/[、,,]/) - .map((item) => item.trim()) - .filter(Boolean) - return limit === undefined ? aliases : aliases.slice(0, limit) -} - -const sourceStatusLabelKeys = { - queued: 'sourceStatuses.queued', - syncing: 'sourceStatuses.syncing', - paused: 'sourceStatuses.paused', - ready: 'sourceStatuses.ready', - failed: 'sourceStatuses.failed' -} as const satisfies Record - -const documentStatusLabelKeys = { - queued: 'documentStatuses.queued', - parsing: 'documentStatuses.parsing', - indexing: 'documentStatuses.indexing', - ready: 'documentStatuses.ready', - failed: 'documentStatuses.failed' -} as const satisfies Record - -const taskKindLabelKeys = { - 'source-sync': 'taskKinds.sourceSync', - 'document-process': 'taskKinds.documentProcess', - 'document-rebuild': 'taskKinds.documentRebuild', - 'library-rebuild': 'taskKinds.libraryRebuild', - 'embedding-rebuild': 'taskKinds.embeddingRebuild', - 'graph-rebuild': 'taskKinds.graphRebuild', - parsing: 'taskKinds.parsing', - embedding: 'taskKinds.embedding', - graph: 'taskKinds.graph' -} as const satisfies Record - -const taskStageLabelKeys = { - queued: 'taskStages.queued', - syncing: 'taskStages.syncing', - reading: 'taskStages.reading', - parsing: 'taskStages.parsing', - chunking: 'taskStages.chunking', - indexing: 'taskStages.indexing', - embedding: 'taskStages.embedding', - graph: 'taskStages.graph', - finalizing: 'taskStages.finalizing' -} as const satisfies Record - -const taskStatusLabelKeys = { - queued: 'taskStatuses.queued', - running: 'taskStatuses.running', - succeeded: 'taskStatuses.succeeded', - failed: 'taskStatuses.failed', - cancelled: 'taskStatuses.cancelled', - skipped: 'taskStatuses.skipped', - interrupted: 'taskStatuses.interrupted' -} as const satisfies Record - -const taskScopeLabelKeys = { - library: 'taskScopes.library', - source: 'taskScopes.source', - document: 'taskScopes.document' -} as const satisfies Record - function toWorkbenchResponse( response: KnowledgeRetrievalResponse, libraryDocumentCount: number @@ -521,3838 +139,6 @@ function toWorkbenchResponse( } } -function resolvedLocale(language: string): string { - return language || 'zh-CN' -} - -type LocaleFormatters = { - compactDateTime: Intl.DateTimeFormat - dateTime: Intl.DateTimeFormat - decimal: Intl.NumberFormat - integer: Intl.NumberFormat - percent: Intl.NumberFormat -} - -const localeFormatters = new Map() - -function getLocaleFormatters(locale: string): LocaleFormatters { - const existing = localeFormatters.get(locale) - if (existing) { - return existing - } - const formatters: LocaleFormatters = { - compactDateTime: new Intl.DateTimeFormat(locale, { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }), - dateTime: new Intl.DateTimeFormat(locale, { - dateStyle: 'medium', - timeStyle: 'short' - }), - decimal: new Intl.NumberFormat(locale, { - minimumFractionDigits: 1, - maximumFractionDigits: 1 - }), - integer: new Intl.NumberFormat(locale), - percent: new Intl.NumberFormat(locale, { - style: 'percent', - maximumFractionDigits: 0 - }) - } - localeFormatters.set(locale, formatters) - return formatters -} - -function formatNumber(value: number, locale: string): string { - return getLocaleFormatters(locale).integer.format(value) -} - -function formatPercent(value: number, locale: string): string { - return getLocaleFormatters(locale).percent.format(value) -} - -function clampProgress(progress: number | undefined): number { - if (!Number.isFinite(progress)) { - return 0 - } - return Math.min(100, Math.max(0, progress ?? 0)) -} - -function formatTime( - value: string | undefined, - locale: string, - t: TFunction<'knowledge'> -): string { - if (!value) { - return t('format.neverSynced') - } - const date = new Date(value) - if (Number.isNaN(date.getTime())) { - return t('format.unknownTime') - } - return getLocaleFormatters(locale).compactDateTime.format(date) -} - -function formatDateTime( - value: string, - locale: string, - t: TFunction<'knowledge'> -): string { - const date = new Date(value) - return Number.isNaN(date.getTime()) - ? t('format.unknownTime') - : getLocaleFormatters(locale).dateTime.format(date) -} - -function formatSize( - size: number | undefined, - locale: string, - t: TFunction<'knowledge'> -): string { - if (!Number.isFinite(size) || (size ?? 0) < 0) { - return t('format.unknownSize') - } - const value = size ?? 0 - const formatters = getLocaleFormatters(locale) - if (value < 1024) { - return `${formatters.integer.format(value)} B` - } - if (value < 1024 * 1024) { - return `${formatters.decimal.format(value / 1024)} KB` - } - return `${formatters.decimal.format(value / 1024 / 1024)} MB` -} - -function formatDocumentLocation( - value: string, - t: TFunction<'knowledge'> -): string { - try { - const url = new URL(value) - if (url.protocol === 'http:' || url.protocol === 'https:') { - return `${url.origin}${url.pathname}` - } - } catch { - // Local paths are intentionally reduced below. - } - const filename = value.split(/[\\/]/u).filter(Boolean).at(-1) - return filename - ? t('format.localFileNamed', { filename }) - : t('format.localFile') -} - -function toErrorMessage( - reason: unknown, - t: TFunction<'knowledge'> -): string { - return reason instanceof Error && reason.message - ? reason.message - : t('errors.operationFailed') -} - -function CreateLibraryWizard({ - onCancel, - onCreate -}: { - onCancel: () => void - onCreate: KnowledgeWorkspaceProps['onCreateLibrary'] -}): React.JSX.Element { - const { t } = useTranslation('knowledge') - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [storageMode, setStorageMode] = - useState('reference') - const [advancedOpen, setAdvancedOpen] = useState(false) - const advancedId = useId() - const [graphEnabled, setGraphEnabled] = useState(false) - const [graphStrategy, setGraphStrategy] = - useState('rules') - const [saving, setSaving] = useState(false) - const [error, setError] = useState() - - const submit = async ( - event: React.FormEvent - ): Promise => { - event.preventDefault() - if (!name.trim()) { - setError(t('validation.libraryNameRequired')) - return - } - setSaving(true) - setError(undefined) - try { - await onCreate({ - name: name.trim(), - description: description.trim(), - storageMode, - graphEnabled, - graphStrategy - }) - onCancel() - } catch (reason) { - setError(toErrorMessage(reason, t)) - } finally { - setSaving(false) - } - } - - return ( -
void submit(event)} - > -
- - {t('create.eyebrow')} - -

- {t('create.title')} -

-

- {t('create.description')} -

-
- -