fix: unify knowledge documents and sources
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: 知识库的内容来源与文档索引合并为统一列表,减少重复记录和操作;支持统一搜索,并保留目录分组和失败来源的恢复入口。
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -64,6 +64,25 @@ Conversation 的检索范围,返回对话并聚焦输入框。
|
||||
|
||||
## 6. 来源操作
|
||||
|
||||
“文档与来源”使用一张按来源组织的表格。单文件、单 URL 来源只有一篇关联文档时,
|
||||
来源与文档合成一行,以文档名为主标题,同步状态、同步时间和索引状态就近显示。
|
||||
不再单独展示同名来源行,也不重复任务入口。目录及其他一对多来源保留来源组标题,
|
||||
文档紧随其后;零文档和失败来源仍保留同步、暂停、重试及移除入口。
|
||||
没有 `sourceId` 或找不到对应来源的文档独立显示。
|
||||
|
||||
搜索同时匹配来源名称、来源位置、文档名称和路径。来源命中时显示该组全部文档;
|
||||
仅文档命中时保留组标题和命中文档。一对多关系按完整数据判断,搜索不能将其临时合成
|
||||
单文档行。移除确认使用来源的完整文档数量,不使用搜索结果数。
|
||||
|
||||
导入文件、目录、URL 及拖放入口保持可用,拖放区使用紧凑的图标与文字提示。
|
||||
单文档行的同步、移除和重新解析收进原生“更多操作”折叠区,解析预览、打开来源、
|
||||
分块和任务保留直接入口。失败文档的重新处理入口仍直接显示。合并行的任务入口包含
|
||||
该来源及该文档的相关任务,保留没有来源 ID 的文档任务。大小与分块数不拆行,操作组
|
||||
按可用宽度换行,窄窗口在表格内部横向滚动。
|
||||
|
||||
解析预览复用现有结果组件;返回列表时恢复搜索、原触发按钮焦点及页面滚动位置。
|
||||
重新解析继续调用文档重建,使用当前设置并保留上次结果,失败时显示可恢复错误。
|
||||
|
||||
文档行提供“打开来源”,Main 根据知识库和文档归属重新读取保存的文件路径或 URL。
|
||||
移除来源使用确认对话框,说明文档数、索引、图谱证据和托管副本影响。初始焦点位于
|
||||
取消,提交期间禁止重复操作,失败时保留对话框和错误。
|
||||
|
||||
@@ -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<string, (event: unknown) => void>()
|
||||
@@ -73,6 +70,10 @@ vi.mock('@antv/g6', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./DocumentResultPreview', () => ({
|
||||
DocumentResultPreview: ({ resultId }: { resultId: string }) => <div>Preview {resultId}</div>
|
||||
}))
|
||||
|
||||
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(<KnowledgeWorkspace {...props} />)
|
||||
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(<KnowledgeWorkspace {...props} sources={[{ ...source, name: document.name }]} />)
|
||||
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(<KnowledgeWorkspace {...props} sources={[{ ...source, name: document.name, status }]} />)
|
||||
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(<KnowledgeWorkspace {...createProps({
|
||||
sources: [{ ...base.sources[0]!, kind: 'url', name: 'Website', documentCount: 2 }],
|
||||
documents: [base.documents[0]!, { ...base.documents[0]!, id: 'second', name: 'second.md' }]
|
||||
})} />)
|
||||
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(<KnowledgeWorkspace {...props} />)
|
||||
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(<PageShell variant="master-detail"><KnowledgeWorkspace {...props} /></PageShell>)
|
||||
const search = screen.getByRole('searchbox', { name: '搜索文档与来源' })
|
||||
fireEvent.change(search, { target: { value: '架构' } })
|
||||
const trigger = screen.getByRole('button', { name: '查看解析结果' })
|
||||
const scroll = trigger.closest<HTMLElement>('.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(<KnowledgeWorkspace {...createProps({
|
||||
sources: [{ ...base.sources[0]!, kind: 'file' }],
|
||||
tasks: [
|
||||
{ ...task, id: 'source-task', sourceId: 'source-1', scope: 'source', documentName: 'Source task' },
|
||||
{ ...task, id: 'document-task', documentId: 'document-1', scope: 'document', documentName: 'Document task' },
|
||||
{ ...task, id: 'unrelated-task', documentId: 'other', scope: 'document', documentName: 'Unrelated task' }
|
||||
]
|
||||
})} />)
|
||||
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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -598,6 +598,14 @@ export const knowledge = {
|
||||
},
|
||||
dropFiles: 'Drop files here to add them to “{{name}}”',
|
||||
sourceMeta: '{{count}} documents · {{time}}',
|
||||
lastSynced: 'Last synced: {{time}}',
|
||||
moreActions: 'More actions',
|
||||
reparse: 'Reparse with current settings',
|
||||
viewResult: 'View parsed result',
|
||||
previewLabel: 'Parsed result: {{name}}',
|
||||
backToList: 'Back to document list',
|
||||
reparsing: 'Reparsing; showing the previous result',
|
||||
reparseFailed: 'Parsing failed; the previous result is still available: {{error}}',
|
||||
syncProgress: '{{name}} sync progress',
|
||||
actions: {
|
||||
pauseSource: 'Pause {{name}}',
|
||||
@@ -618,10 +626,10 @@ export const knowledge = {
|
||||
removing: 'Removing…'
|
||||
},
|
||||
table: {
|
||||
title: 'Documents and index',
|
||||
title: 'Documents and sources',
|
||||
empty:
|
||||
'No documents yet. Processing status appears here after you import a content source.',
|
||||
noResults: 'No documents match your search.',
|
||||
'No documents or sources yet. Import files, a folder, or a URL to see sync and index status here.',
|
||||
noResults: 'No documents or sources match. Try another name or path.',
|
||||
columns: {
|
||||
document: 'Document',
|
||||
status: 'Status',
|
||||
@@ -633,8 +641,8 @@ export const knowledge = {
|
||||
}
|
||||
},
|
||||
search: {
|
||||
label: 'Search documents',
|
||||
placeholder: 'Search names or paths'
|
||||
label: 'Search documents and sources',
|
||||
placeholder: 'Search document or source names and paths'
|
||||
},
|
||||
indexProgress: '{{name}} index progress'
|
||||
},
|
||||
|
||||
@@ -562,6 +562,14 @@ export const knowledge = {
|
||||
},
|
||||
dropFiles: '将文件拖到这里,加入“{{name}}”',
|
||||
sourceMeta: '{{count}} 个文档 · {{time}}',
|
||||
lastSynced: '最近同步:{{time}}',
|
||||
moreActions: '更多操作',
|
||||
reparse: '使用当前设置重新解析',
|
||||
viewResult: '查看解析结果',
|
||||
previewLabel: '解析结果:{{name}}',
|
||||
backToList: '返回文档列表',
|
||||
reparsing: '正在重新解析,当前显示上次结果',
|
||||
reparseFailed: '本次解析失败,上次结果仍可用:{{error}}',
|
||||
syncProgress: '{{name}} 同步进度',
|
||||
actions: {
|
||||
pauseSource: '暂停 {{name}}',
|
||||
@@ -582,9 +590,9 @@ export const knowledge = {
|
||||
removing: '正在移除…'
|
||||
},
|
||||
table: {
|
||||
title: '文档与索引',
|
||||
empty: '尚无文档。导入内容来源后,处理状态会显示在这里。',
|
||||
noResults: '没有与搜索条件匹配的文档。',
|
||||
title: '文档与来源',
|
||||
empty: '尚无文档或来源。导入文件、目录或 URL 后,可在这里查看同步和索引状态。',
|
||||
noResults: '没有匹配的文档或来源,请尝试其他名称或路径。',
|
||||
columns: {
|
||||
document: '文档',
|
||||
status: '状态',
|
||||
@@ -596,8 +604,8 @@ export const knowledge = {
|
||||
}
|
||||
},
|
||||
search: {
|
||||
label: '搜索文档',
|
||||
placeholder: '搜索名称或路径'
|
||||
label: '搜索文档与来源',
|
||||
placeholder: '搜索文档、来源名称或路径'
|
||||
},
|
||||
indexProgress: '{{name}} 索引进度'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
import type {
|
||||
KnowledgeSourceStatus,
|
||||
KnowledgeDocumentStatus,
|
||||
KnowledgeWorkspaceProps,
|
||||
KnowledgeLibrary,
|
||||
KnowledgeDocumentItem,
|
||||
KnowledgeSource,
|
||||
KnowledgeGraphStrategy
|
||||
} from './types'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { getLocaleFormatters, resolvedLocale, formatPercent, formatNumber } from './formatting'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRef, useState, useMemo } from 'react'
|
||||
import { toErrorMessage, clampProgress, taskStageLabelKeys } from './helpers'
|
||||
import { ScopeBadge } from '../WorkspacePrimitives'
|
||||
import { DocumentResultPreview } from '../DocumentResultPreview'
|
||||
import {
|
||||
FilePlus2,
|
||||
FolderOpen,
|
||||
Link2,
|
||||
X,
|
||||
UploadCloud,
|
||||
FileText,
|
||||
ListChecks,
|
||||
CirclePause,
|
||||
RotateCcw,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Search
|
||||
} from 'lucide-react'
|
||||
import { RemoveSourceDialog } from './RemoveSourceDialog'
|
||||
|
||||
const sourceStatusLabelKeys = {
|
||||
queued: 'sourceStatuses.queued',
|
||||
syncing: 'sourceStatuses.syncing',
|
||||
paused: 'sourceStatuses.paused',
|
||||
ready: 'sourceStatuses.ready',
|
||||
failed: 'sourceStatuses.failed'
|
||||
} as const satisfies Record<KnowledgeSourceStatus, string>
|
||||
|
||||
const documentStatusLabelKeys = {
|
||||
queued: 'documentStatuses.queued',
|
||||
parsing: 'documentStatuses.parsing',
|
||||
indexing: 'documentStatuses.indexing',
|
||||
ready: 'documentStatuses.ready',
|
||||
failed: 'documentStatuses.failed'
|
||||
} as const satisfies Record<KnowledgeDocumentStatus, string>
|
||||
|
||||
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 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')
|
||||
}
|
||||
|
||||
export function DocumentsView({
|
||||
documents,
|
||||
library,
|
||||
onImportDirectory,
|
||||
onImportFiles,
|
||||
onImportUrl,
|
||||
onOpenDocumentSource,
|
||||
onPauseSource,
|
||||
onManageChunks,
|
||||
onRemoveSource,
|
||||
onRebuildDocument,
|
||||
onRetrySource,
|
||||
onSyncSource,
|
||||
onViewTasks,
|
||||
sources,
|
||||
tasks
|
||||
}: Pick<
|
||||
KnowledgeWorkspaceProps,
|
||||
| 'documents'
|
||||
| 'onImportDirectory'
|
||||
| 'onImportFiles'
|
||||
| 'onImportUrl'
|
||||
| 'onOpenDocumentSource'
|
||||
| 'onPauseSource'
|
||||
| 'onRemoveSource'
|
||||
| 'onRebuildDocument'
|
||||
| 'onRetrySource'
|
||||
| 'onSyncSource'
|
||||
| 'sources'
|
||||
| 'tasks'
|
||||
> & {
|
||||
library: KnowledgeLibrary
|
||||
onManageChunks: (document: KnowledgeDocumentItem) => void
|
||||
onViewTasks: (context: {
|
||||
documentId?: string
|
||||
sourceId?: string
|
||||
}) => void
|
||||
}): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [viewedDocumentId, setViewedDocumentId] = useState<string>()
|
||||
const previewBackRef = useRef<HTMLButtonElement>(null)
|
||||
const previewOriginRef = useRef<{ trigger: HTMLElement; scroll?: HTMLElement; top: number } | undefined>(undefined)
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [pending, setPending] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [removingSource, setRemovingSource] =
|
||||
useState<KnowledgeSource>()
|
||||
const [askStrategy, setAskStrategy] =
|
||||
useState<Exclude<KnowledgeGraphStrategy, 'ask'>>('hybrid')
|
||||
const importGraphStrategy =
|
||||
library.graphStrategy === 'ask' ? askStrategy : undefined
|
||||
|
||||
const run = async (
|
||||
id: string,
|
||||
action: () => void | Promise<void>
|
||||
): Promise<boolean> => {
|
||||
setPending(id)
|
||||
setError(undefined)
|
||||
try {
|
||||
await action()
|
||||
return true
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
return false
|
||||
} finally {
|
||||
setPending(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const importFiles = (files: File[]): void => {
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
void run('files', () =>
|
||||
onImportFiles(library.id, files, importGraphStrategy)
|
||||
)
|
||||
}
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase(locale)
|
||||
const matches = (value: string): boolean => value.toLocaleLowerCase(locale).includes(normalized)
|
||||
const bySource = new Map<string, KnowledgeDocumentItem[]>()
|
||||
const sourceIds = new Set(sources.map((source) => source.id))
|
||||
const result: { key: string; source?: KnowledgeSource; document?: KnowledgeDocumentItem; grouped?: boolean }[] = []
|
||||
for (const document of documents) {
|
||||
if (document.sourceId && sourceIds.has(document.sourceId)) {
|
||||
const group = bySource.get(document.sourceId) ?? []
|
||||
group.push(document)
|
||||
bySource.set(document.sourceId, group)
|
||||
}
|
||||
}
|
||||
for (const source of sources) {
|
||||
const related = bySource.get(source.id) ?? []
|
||||
const sourceMatches = matches(`${source.name} ${source.location}`)
|
||||
const visible = sourceMatches ? related : related.filter((document) => matches(`${document.name} ${document.path ?? ''}`))
|
||||
if (!sourceMatches && visible.length === 0) continue
|
||||
// Decide from the full group so filtering never changes source ownership.
|
||||
if (source.kind !== 'directory' && related.length === 1) {
|
||||
result.push({ key: `source:${source.id}`, source, document: related[0] })
|
||||
} else {
|
||||
result.push({ key: `source:${source.id}`, source })
|
||||
for (const document of visible) result.push({ key: `document:${document.id}`, document, grouped: true })
|
||||
}
|
||||
}
|
||||
for (const document of documents) {
|
||||
if ((!document.sourceId || !sourceIds.has(document.sourceId)) && matches(`${document.name} ${document.path ?? ''}`)) {
|
||||
result.push({ key: `document:${document.id}`, document })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [documents, sources, locale, query])
|
||||
const viewedDocument = useMemo(() => documents.find((document) => document.id === viewedDocumentId), [documents, viewedDocumentId])
|
||||
|
||||
const sourceStatus = (source: KnowledgeSource): React.JSX.Element => (
|
||||
<span className={`knowledge-source-row__status knowledge-source-row__status--${source.status}`}>
|
||||
{t(sourceStatusLabelKeys[source.status])}
|
||||
{source.status === 'syncing' && source.progress !== undefined
|
||||
? ` · ${formatPercent(clampProgress(source.progress) / 100, locale)}` : ''}
|
||||
</span>
|
||||
)
|
||||
|
||||
const sourceActions = (source: KnowledgeSource): React.JSX.Element => (
|
||||
<div className="knowledge-source-row__actions">
|
||||
<button
|
||||
aria-label={t(source.status === 'syncing' ? 'documents.actions.pauseSource' : source.status === 'failed' ? 'documents.actions.retrySource' : 'documents.actions.syncSource', { name: source.name })}
|
||||
className="secondary-button"
|
||||
disabled={pending === source.id}
|
||||
onClick={() => void run(source.id, () => source.status === 'syncing' ? onPauseSource(source.id) : source.status === 'failed' ? onRetrySource(source.id) : onSyncSource(source.id))}
|
||||
type="button"
|
||||
>
|
||||
{source.status === 'syncing' ? <CirclePause aria-hidden="true" size={14} /> : source.status === 'failed' ? <RotateCcw aria-hidden="true" size={14} /> : <RefreshCw aria-hidden="true" size={14} />}
|
||||
{t(source.status === 'syncing' ? 'actions.pause' : source.status === 'failed' ? 'actions.retry' : 'actions.sync')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('documents.actions.removeSource', { name: source.name })}
|
||||
className="danger-button danger-button--quiet"
|
||||
disabled={pending === source.id}
|
||||
onClick={() => setRemovingSource(source)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />{t('actions.remove')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="knowledge-documents">
|
||||
{viewedDocument?.resultId && <section aria-label={t('documents.previewLabel', { name: viewedDocument.name })}>
|
||||
<div className="document-result-actions">
|
||||
<button ref={previewBackRef} type="button" className="secondary-button" onClick={() => {
|
||||
setViewedDocumentId(undefined)
|
||||
requestAnimationFrame(() => {
|
||||
previewOriginRef.current?.trigger.focus()
|
||||
if (previewOriginRef.current?.scroll) previewOriginRef.current.scroll.scrollTop = previewOriginRef.current.top
|
||||
})
|
||||
}}>{t('documents.backToList')}</button>
|
||||
<strong>{library.name} · {viewedDocument.name}</strong><ScopeBadge scope={{ kind: 'global' }} />
|
||||
<button type="button" className="secondary-button" disabled={Boolean(pending)} onClick={() => void run(`retry:${viewedDocument.id}`, () => onRebuildDocument(library.id, viewedDocument.id))}>{t('documents.reparse')}</button>
|
||||
</div>
|
||||
{pending && <p role="status">{t('documents.reparsing')}</p>}
|
||||
{error && <p role="alert">{t('documents.reparseFailed', { error })}</p>}
|
||||
<DocumentResultPreview key={viewedDocument.resultId} resultId={viewedDocument.resultId} allowAddImages />
|
||||
</section>}
|
||||
<div hidden={Boolean(viewedDocument?.resultId)}>
|
||||
<section aria-labelledby="documents-title">
|
||||
<div
|
||||
className="knowledge-documents__section-heading"
|
||||
>
|
||||
<div>
|
||||
<h3 id="documents-title">
|
||||
{t('documents.table.title')}
|
||||
</h3>
|
||||
<p className="knowledge-section-description">
|
||||
{t(
|
||||
library.graphEnabled
|
||||
? 'documents.sources.descriptionWithGraph'
|
||||
: 'documents.sources.description'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="knowledge-documents__import-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<FilePlus2 aria-hidden="true" size={15} />
|
||||
{t('actions.importFiles')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
void run('directory', () =>
|
||||
onImportDirectory(
|
||||
library.id,
|
||||
[],
|
||||
importGraphStrategy
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen aria-hidden="true" size={15} />
|
||||
{t('actions.importDirectory')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setUrlOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<Link2 aria-hidden="true" size={15} />
|
||||
{t('actions.importUrl')}
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
importFiles(Array.from(event.currentTarget.files ?? []))
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{library.graphEnabled && library.graphStrategy === 'ask' && (
|
||||
<label className="knowledge-documents__import-strategy knowledge-field">
|
||||
{t('documents.importStrategy')}
|
||||
<select
|
||||
onChange={(event) =>
|
||||
setAskStrategy(
|
||||
event.currentTarget.value as Exclude<
|
||||
KnowledgeGraphStrategy,
|
||||
'ask'
|
||||
>
|
||||
)
|
||||
}
|
||||
value={askStrategy}
|
||||
>
|
||||
<option value="rules">
|
||||
{t('documents.importStrategies.rules')}
|
||||
</option>
|
||||
<option value="model">
|
||||
{t('documents.importStrategies.model')}
|
||||
</option>
|
||||
<option value="hybrid">
|
||||
{t('documents.importStrategies.hybrid')}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{urlOpen && (
|
||||
<form
|
||||
aria-label={t('documents.urlImport.ariaLabel')}
|
||||
className="knowledge-documents__url-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const value = url.trim()
|
||||
if (!value) {
|
||||
setError(t('validation.urlRequired'))
|
||||
return
|
||||
}
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(value)
|
||||
} catch {
|
||||
setError(t('validation.urlInvalid'))
|
||||
return
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
setError(t('validation.urlProtocol'))
|
||||
return
|
||||
}
|
||||
void run(
|
||||
'url',
|
||||
() =>
|
||||
onImportUrl(
|
||||
library.id,
|
||||
parsed.href,
|
||||
importGraphStrategy
|
||||
)
|
||||
).then((succeeded) => {
|
||||
if (succeeded) {
|
||||
setUrl('')
|
||||
setUrlOpen(false)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t('documents.urlImport.addressAriaLabel')}
|
||||
onChange={(event) => setUrl(event.currentTarget.value)}
|
||||
placeholder="https://"
|
||||
type="url"
|
||||
value={url}
|
||||
/>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={pending === 'url'}
|
||||
type="submit"
|
||||
>
|
||||
{t('actions.import')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('documents.urlImport.closeAriaLabel')}
|
||||
className="secondary-button"
|
||||
onClick={() => setUrlOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={15} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`knowledge-documents__drop-zone${
|
||||
dragging ? ' knowledge-documents__drop-zone--dragging' : ''
|
||||
}`}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
setDragging(false)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
importFiles(Array.from(event.dataTransfer.files))
|
||||
}}
|
||||
>
|
||||
<UploadCloud aria-hidden="true" size={16} />
|
||||
<div>
|
||||
{t('documents.dropFiles', { name: library.name })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="knowledge-inline-error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="knowledge-documents__toolbar"
|
||||
>
|
||||
<label
|
||||
className="knowledge-documents__search"
|
||||
>
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
size={15}
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{t('documents.search.label')}
|
||||
</span>
|
||||
<input
|
||||
aria-label={t('documents.search.label')}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t('documents.search.placeholder')}
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="knowledge-documents__empty">
|
||||
{documents.length === 0 && sources.length === 0
|
||||
? t('documents.table.empty')
|
||||
: t('documents.table.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="knowledge-documents__table-scroll">
|
||||
<table aria-label={t('documents.table.title')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{t('documents.table.columns.document')}
|
||||
</th>
|
||||
<th>
|
||||
{t('documents.table.columns.processingStatus')}
|
||||
</th>
|
||||
<th className="knowledge-document-metric">
|
||||
{t('documents.table.columns.chunks')}
|
||||
</th>
|
||||
<th className="knowledge-document-metric">
|
||||
{t('documents.table.columns.size')}
|
||||
</th>
|
||||
<th>
|
||||
{t('documents.table.columns.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ key, source, document, grouped }) => {
|
||||
if (!document && source) return (
|
||||
<tr key={key} className="knowledge-source-group">
|
||||
<td colSpan={5}>
|
||||
<div className="knowledge-source-row">
|
||||
<div className="knowledge-source-row__main">
|
||||
<div className="knowledge-source-row__identity">
|
||||
{source.kind === 'directory' ? <FolderOpen aria-hidden="true" size={16} /> : source.kind === 'url' ? <Link2 aria-hidden="true" size={16} /> : <FileText aria-hidden="true" size={16} />}
|
||||
<strong title={source.name}>{source.name}</strong>
|
||||
{sourceStatus(source)}
|
||||
</div>
|
||||
<div className="knowledge-source-row__meta">{t('documents.sourceMeta', {
|
||||
count: formatNumber(source.documentCount, locale),
|
||||
time: formatTime(source.lastSyncedAt, locale, t)
|
||||
})}</div>
|
||||
{source.error && <div className="knowledge-source-row__error">{source.error}</div>}
|
||||
</div>
|
||||
<div className="knowledge-document-actions">
|
||||
{tasks?.some((task) => task.sourceId === source.id) && <button type="button" className="secondary-button" onClick={() => onViewTasks({ sourceId: source.id })}><ListChecks aria-hidden="true" size={14} />{t('actions.viewTasks')}</button>}
|
||||
{sourceActions(source)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
if (!document) return null
|
||||
const relatedTasks = tasks?.filter(
|
||||
(task) => task.documentId === document.id || (source && task.sourceId === source.id)
|
||||
) ?? []
|
||||
const activeTask = relatedTasks.find(
|
||||
(task) =>
|
||||
task.documentId === document.id &&
|
||||
(task.status === 'queued' || task.status === 'running')
|
||||
)
|
||||
return (
|
||||
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined}>
|
||||
<td>
|
||||
<strong>{document.name}</strong>
|
||||
{document.path && !source && (
|
||||
<div className="knowledge-document-path">
|
||||
{formatDocumentLocation(document.path, t)}
|
||||
</div>
|
||||
)}
|
||||
{source && <div className="knowledge-source-row__meta">{t('documents.lastSynced', { time: formatTime(source.lastSyncedAt, locale, t) })}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<div className="knowledge-document-status">
|
||||
{source && sourceStatus(source)}
|
||||
<span
|
||||
className={`knowledge-document-status__durable knowledge-document-status__durable--${document.status}`}
|
||||
>
|
||||
{t(documentStatusLabelKeys[document.status])}
|
||||
</span>
|
||||
<span className="knowledge-document-status__channels">
|
||||
<span>
|
||||
{t(
|
||||
`indexStatuses.text.${
|
||||
document.textIndexStatus ??
|
||||
(document.status === 'ready'
|
||||
? 'ready'
|
||||
: document.status === 'failed'
|
||||
? 'failed'
|
||||
: 'waiting')
|
||||
}`
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
`indexStatuses.vector.${
|
||||
document.vectorIndexStatus ?? 'disabled'
|
||||
}`
|
||||
)}
|
||||
</span>
|
||||
{document.graphIndexStatus !== undefined && (
|
||||
<span>
|
||||
{t(
|
||||
`indexStatuses.graph.${document.graphIndexStatus}`
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{activeTask && (
|
||||
<span className="knowledge-document-status__active">
|
||||
{t(taskStageLabelKeys[activeTask.stage])}
|
||||
{' · '}
|
||||
{formatPercent(
|
||||
clampProgress(activeTask.progress) / 100,
|
||||
locale
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{document.error && (
|
||||
<div className="knowledge-document-error">
|
||||
{document.error}
|
||||
</div>
|
||||
)}
|
||||
{source?.error && source.error !== document.error && <div className="knowledge-source-row__error">{source.error}</div>}
|
||||
</td>
|
||||
<td className="knowledge-document-metric">
|
||||
{document.chunkCount === undefined
|
||||
? '—'
|
||||
: formatNumber(document.chunkCount, locale)}
|
||||
</td>
|
||||
<td className="knowledge-document-metric">
|
||||
{formatSize(document.size, locale, t)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="knowledge-document-actions">
|
||||
{document.resultId && <button type="button" className="secondary-button" onClick={(event) => {
|
||||
const scroll = event.currentTarget.closest<HTMLElement>('.workspace-panel-scroll') ?? undefined
|
||||
previewOriginRef.current = { trigger: event.currentTarget, scroll, top: scroll?.scrollTop ?? 0 }
|
||||
setViewedDocumentId(document.id)
|
||||
requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 })
|
||||
}}>{t('documents.viewResult')}</button>}
|
||||
{relatedTasks.length > 0 && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
onViewTasks({ documentId: document.id, ...(source ? { sourceId: source.id } : {}) })
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ListChecks aria-hidden="true" size={14} />
|
||||
{t('actions.viewTasks')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'documents.actions.openDocumentSource',
|
||||
{ name: document.name }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={pending === `open:${document.id}`}
|
||||
onClick={() =>
|
||||
void run(`open:${document.id}`, () =>
|
||||
onOpenDocumentSource(
|
||||
library.id,
|
||||
document.id
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.openSource')}
|
||||
</button>
|
||||
{document.status === 'failed' && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'documents.actions.retryDocument',
|
||||
{ name: document.name }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={pending === `retry:${document.id}`}
|
||||
onClick={() =>
|
||||
void run(`retry:${document.id}`, () =>
|
||||
onRebuildDocument(
|
||||
library.id,
|
||||
document.id
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw aria-hidden="true" size={14} />
|
||||
{t('actions.retryDocument')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={(document.chunkCount ?? 0) === 0}
|
||||
onClick={() => onManageChunks(document)}
|
||||
type="button"
|
||||
>
|
||||
{t('chunks.title')}
|
||||
</button>
|
||||
</div>
|
||||
{(source || (document.resultId && document.status !== 'failed')) && <details className="knowledge-document-more">
|
||||
<summary>{t('documents.moreActions')}</summary>
|
||||
<div className="knowledge-document-actions">
|
||||
{document.resultId && document.status !== 'failed' && <button type="button" className="secondary-button" disabled={Boolean(activeTask) || pending === `retry:${document.id}`} onClick={() => void run(`retry:${document.id}`, () => onRebuildDocument(library.id, document.id))}>{t('documents.reparse')}</button>}
|
||||
{source && sourceActions(source)}
|
||||
</div>
|
||||
</details>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{removingSource && (
|
||||
<RemoveSourceDialog
|
||||
onCancel={() => setRemovingSource(undefined)}
|
||||
onConfirm={() => onRemoveSource(removingSource.id)}
|
||||
source={removingSource}
|
||||
storageMode={library.storageMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { KnowledgeGraphNode, KnowledgeGraphRelation } from './types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useRef, useId, useEffect } from 'react'
|
||||
import { activateModalFocus, trapTabFocus } from '../dialog-focus'
|
||||
import { toErrorMessage } from './helpers'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertCircle, Trash2 } from 'lucide-react'
|
||||
|
||||
export type GraphDestructiveAction =
|
||||
| {
|
||||
kind: 'delete-entity'
|
||||
node: KnowledgeGraphNode
|
||||
relationCount: number
|
||||
}
|
||||
| {
|
||||
kind: 'delete-relation'
|
||||
relation: KnowledgeGraphRelation
|
||||
sourceLabel: string
|
||||
targetLabel: string
|
||||
typeLabel: string
|
||||
}
|
||||
| {
|
||||
kind: 'merge-entities'
|
||||
source: KnowledgeGraphNode
|
||||
target: KnowledgeGraphNode
|
||||
}
|
||||
|
||||
export function GraphDestructiveDialog({
|
||||
action,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
action: GraphDestructiveAction
|
||||
onCancel: () => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
const title =
|
||||
action.kind === 'delete-entity'
|
||||
? t('graph.confirm.deleteEntity.title', {
|
||||
name: action.node.label
|
||||
})
|
||||
: action.kind === 'delete-relation'
|
||||
? t('graph.confirm.deleteRelation.title', {
|
||||
type: action.typeLabel
|
||||
})
|
||||
: t('graph.confirm.merge.title', {
|
||||
source: action.source.label,
|
||||
target: action.target.label
|
||||
})
|
||||
const description =
|
||||
action.kind === 'delete-entity'
|
||||
? t('graph.confirm.deleteEntity.description', {
|
||||
name: action.node.label,
|
||||
count: action.relationCount
|
||||
})
|
||||
: action.kind === 'delete-relation'
|
||||
? t('graph.confirm.deleteRelation.description', {
|
||||
source: action.sourceLabel,
|
||||
target: action.targetLabel,
|
||||
type: action.typeLabel
|
||||
})
|
||||
: t('graph.confirm.merge.description', {
|
||||
source: action.source.label,
|
||||
target: action.target.label
|
||||
})
|
||||
const confirmLabel =
|
||||
action.kind === 'delete-entity'
|
||||
? t('graph.confirm.deleteEntity.action')
|
||||
: action.kind === 'delete-relation'
|
||||
? t('graph.confirm.deleteRelation.action')
|
||||
: t('graph.confirm.merge.action')
|
||||
|
||||
useEffect(
|
||||
() => activateModalFocus(() => cancelRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
const confirm = async (): Promise<void> => {
|
||||
setPending(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onConfirm()
|
||||
onCancel()
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className="knowledge-confirm-dialog-backdrop"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !pending) {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="alertdialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<section className="knowledge-confirm-dialog">
|
||||
<AlertCircle aria-hidden="true" size={26} />
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<p id={descriptionId}>{description}</p>
|
||||
{error && (
|
||||
<p className="knowledge-inline-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="knowledge-confirm-dialog__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={pending}
|
||||
onClick={onCancel}
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={pending}
|
||||
onClick={() => void confirm()}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
{pending ? t('graph.confirm.processing') : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import type {
|
||||
KnowledgeGraphNode,
|
||||
KnowledgeEntityUpdate,
|
||||
KnowledgeRelationInput,
|
||||
KnowledgeGraphRelation
|
||||
} from './types'
|
||||
import {
|
||||
type KnowledgeOntologySettings,
|
||||
getKnowledgeOntologyDisplayDefinitions,
|
||||
normalizeEntityTypeAlias,
|
||||
normalizeRelationTypeAlias,
|
||||
isRelationEndpointAllowed
|
||||
} from '../../../shared/knowledge-ontology'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { resolvedLocale } from './formatting'
|
||||
import { parseAliases } from './helpers'
|
||||
|
||||
export function EntityEditor({
|
||||
node,
|
||||
ontology,
|
||||
onCancel,
|
||||
onSave
|
||||
}: {
|
||||
node?: KnowledgeGraphNode
|
||||
ontology: KnowledgeOntologySettings
|
||||
onCancel: () => void
|
||||
onSave: (update: KnowledgeEntityUpdate) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const display = useMemo(
|
||||
() =>
|
||||
getKnowledgeOntologyDisplayDefinitions(
|
||||
ontology,
|
||||
resolvedLocale(i18n.resolvedLanguage ?? i18n.language) === 'zh-CN'
|
||||
? 'zh'
|
||||
: 'en'
|
||||
),
|
||||
[i18n.language, i18n.resolvedLanguage, ontology]
|
||||
)
|
||||
const [label, setLabel] = useState(node?.label ?? '')
|
||||
const [type, setType] = useState(
|
||||
normalizeEntityTypeAlias(node?.type, ontology)
|
||||
)
|
||||
const [description, setDescription] = useState(node?.description ?? '')
|
||||
const [aliases, setAliases] = useState(
|
||||
(node?.aliases ?? []).join(t('format.listSeparator'))
|
||||
)
|
||||
|
||||
return (
|
||||
<form
|
||||
aria-label={
|
||||
node ? t('entityEditor.editAriaLabel') : t('entityEditor.addAriaLabel')
|
||||
}
|
||||
className="knowledge-graph__form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void onSave({
|
||||
label: label.trim(),
|
||||
type: type.trim(),
|
||||
description: description.trim(),
|
||||
aliases: parseAliases(aliases)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.name')}
|
||||
<input
|
||||
onChange={(event) => setLabel(event.currentTarget.value)}
|
||||
required
|
||||
value={label}
|
||||
/>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.type')}
|
||||
<select
|
||||
onChange={(event) => setType(event.currentTarget.value)}
|
||||
required
|
||||
value={type}
|
||||
>
|
||||
{display.entityTypes.map((definition) => (
|
||||
<option key={definition.id} value={definition.id}>
|
||||
{definition.label} ({definition.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.description')}
|
||||
<textarea
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
rows={3}
|
||||
value={description}
|
||||
/>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.aliases')}
|
||||
<input
|
||||
onChange={(event) => setAliases(event.currentTarget.value)}
|
||||
value={aliases}
|
||||
/>
|
||||
</label>
|
||||
<div className="knowledge-graph__form-actions">
|
||||
<button className="primary-button">
|
||||
{node ? t('actions.saveEntity') : t('actions.addEntity')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function RelationForm({
|
||||
nodes,
|
||||
ontology,
|
||||
onCancel,
|
||||
onSave,
|
||||
relation,
|
||||
sourceId
|
||||
}: {
|
||||
nodes: readonly KnowledgeGraphNode[]
|
||||
ontology: KnowledgeOntologySettings
|
||||
onCancel: () => void
|
||||
onSave: (
|
||||
input: KnowledgeRelationInput
|
||||
) => void | Promise<void>
|
||||
relation?: KnowledgeGraphRelation
|
||||
sourceId: string
|
||||
}): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const display = useMemo(
|
||||
() =>
|
||||
getKnowledgeOntologyDisplayDefinitions(
|
||||
ontology,
|
||||
resolvedLocale(i18n.resolvedLanguage ?? i18n.language) === 'zh-CN'
|
||||
? 'zh'
|
||||
: 'en'
|
||||
),
|
||||
[i18n.language, i18n.resolvedLanguage, ontology]
|
||||
)
|
||||
const [source, setSource] = useState(relation?.sourceId ?? sourceId)
|
||||
const [target, setTarget] = useState(
|
||||
relation?.targetId ??
|
||||
nodes.find((node) => node.id !== sourceId)?.id ??
|
||||
''
|
||||
)
|
||||
const [type, setType] = useState(
|
||||
normalizeRelationTypeAlias(relation?.type, ontology) ?? ''
|
||||
)
|
||||
const [description, setDescription] = useState(
|
||||
relation?.description ?? ''
|
||||
)
|
||||
const sourceNode = nodes.find((node) => node.id === source)
|
||||
const targetNode = nodes.find((node) => node.id === target)
|
||||
const allowedRelationTypes = display.relationTypes.filter(
|
||||
(definition) =>
|
||||
!sourceNode ||
|
||||
!targetNode ||
|
||||
isRelationEndpointAllowed(
|
||||
definition.id,
|
||||
sourceNode.type,
|
||||
targetNode.type,
|
||||
ontology
|
||||
)
|
||||
)
|
||||
const selectedType = allowedRelationTypes.some(
|
||||
(definition) => definition.id === type
|
||||
)
|
||||
? type
|
||||
: ''
|
||||
|
||||
return (
|
||||
<form
|
||||
aria-label={
|
||||
relation
|
||||
? t('relationEditor.editAriaLabel')
|
||||
: t('relationEditor.addAriaLabel')
|
||||
}
|
||||
className="knowledge-graph__relation-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void onSave({
|
||||
sourceId: source,
|
||||
targetId: target,
|
||||
type: type.trim(),
|
||||
description: description.trim()
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.source')}
|
||||
<select
|
||||
onChange={(event) => setSource(event.currentTarget.value)}
|
||||
required
|
||||
value={source}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{node.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.target')}
|
||||
<select
|
||||
onChange={(event) => setTarget(event.currentTarget.value)}
|
||||
required
|
||||
value={target}
|
||||
>
|
||||
<option disabled value="">
|
||||
{t('graph.selectEntity')}
|
||||
</option>
|
||||
{nodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{node.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.relationType')}
|
||||
<select
|
||||
onChange={(event) => setType(event.currentTarget.value)}
|
||||
required
|
||||
value={selectedType}
|
||||
>
|
||||
<option disabled value="">
|
||||
{t('relationEditor.selectType')}
|
||||
</option>
|
||||
{allowedRelationTypes.map((definition) => (
|
||||
<option key={definition.id} value={definition.id}>
|
||||
{definition.label} ({definition.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{allowedRelationTypes.length === 0 && (
|
||||
<span className="knowledge-settings__field-help">
|
||||
{t('relationEditor.noCompatibleTypes')}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.notes')}
|
||||
<input
|
||||
onChange={(event) =>
|
||||
setDescription(event.currentTarget.value)
|
||||
}
|
||||
value={description}
|
||||
/>
|
||||
</label>
|
||||
<div className="knowledge-graph__form-actions">
|
||||
<button className="primary-button">
|
||||
{relation ? t('actions.saveRelation') : t('actions.addRelation')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
import type { KnowledgeGraphNode, KnowledgeGraphRelation, KnowledgeWorkspaceProps } from './types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
ArrowRight,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
ZoomOut,
|
||||
ZoomIn,
|
||||
RotateCcw,
|
||||
Network,
|
||||
X,
|
||||
Pencil,
|
||||
Trash2,
|
||||
GitMerge
|
||||
} from 'lucide-react'
|
||||
import { PageTabs } from '../WorkspacePrimitives'
|
||||
import {
|
||||
type KnowledgeOntologySettings,
|
||||
getKnowledgeOntologyDisplayDefinitions,
|
||||
normalizeEntityTypeAlias,
|
||||
normalizeRelationTypeAlias
|
||||
} from '../../../shared/knowledge-ontology'
|
||||
import { resolvedLocale, formatPercent, formatNumber } from './formatting'
|
||||
import { useMemo, useCallback, useState, useRef } from 'react'
|
||||
import { type GraphDestructiveAction, GraphDestructiveDialog } from './GraphDestructiveDialog'
|
||||
import { toErrorMessage } from './helpers'
|
||||
import { InlineHelp } from '../InlineHelp'
|
||||
import { KnowledgeGraphChartLoader } from './KnowledgeGraphChartLoader'
|
||||
import { EntityEditor, RelationForm } from './GraphEditors'
|
||||
|
||||
type GraphSidebarTab = 'topology' | 'details'
|
||||
|
||||
function GraphRelationPath({
|
||||
nodeMap,
|
||||
onSelectNode,
|
||||
relation,
|
||||
relationLabel
|
||||
}: {
|
||||
nodeMap: ReadonlyMap<string, KnowledgeGraphNode>
|
||||
onSelectNode: (nodeId: string) => void
|
||||
relation: KnowledgeGraphRelation
|
||||
relationLabel: (type: string) => string
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const source = nodeMap.get(relation.sourceId)
|
||||
const target = nodeMap.get(relation.targetId)
|
||||
|
||||
return (
|
||||
<div className="knowledge-graph__relation-path">
|
||||
<button
|
||||
className="knowledge-graph__entity-link"
|
||||
disabled={!source}
|
||||
onClick={() => source && onSelectNode(source.id)}
|
||||
type="button"
|
||||
>
|
||||
{source?.label ?? t('graph.unknownEntity')}
|
||||
</button>
|
||||
<span className="knowledge-graph__relation-type">
|
||||
<ArrowRight aria-hidden="true" size={13} />
|
||||
{relationLabel(relation.type)}
|
||||
</span>
|
||||
<button
|
||||
className="knowledge-graph__entity-link"
|
||||
disabled={!target}
|
||||
onClick={() => target && onSelectNode(target.id)}
|
||||
type="button"
|
||||
>
|
||||
{target?.label ?? t('graph.unknownEntity')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GraphSidebarNavigation({
|
||||
onChange,
|
||||
relationCount,
|
||||
value
|
||||
}: {
|
||||
onChange: (value: GraphSidebarTab) => void
|
||||
relationCount: number
|
||||
value: GraphSidebarTab
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
return (
|
||||
<PageTabs
|
||||
ariaLabel={t('graph.sidebar.ariaLabel')}
|
||||
idPrefix="knowledge-graph-sidebar"
|
||||
onChange={onChange}
|
||||
tabs={[
|
||||
{
|
||||
id: 'topology',
|
||||
label: t('graph.sidebar.topology'),
|
||||
count: relationCount
|
||||
},
|
||||
{
|
||||
id: 'details',
|
||||
label: t('graph.sidebar.details')
|
||||
}
|
||||
]}
|
||||
value={value}
|
||||
variant="segmented"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function GraphView({
|
||||
evidence,
|
||||
graphNodes,
|
||||
graphRelations,
|
||||
libraryId,
|
||||
ontology,
|
||||
onCreateEntity,
|
||||
onCreateRelation,
|
||||
onDeleteEntity,
|
||||
onDeleteRelation,
|
||||
onMergeEntities,
|
||||
onMoveNode,
|
||||
onOpenEvidence,
|
||||
onReextractGraph,
|
||||
onUpdateEntity,
|
||||
onUpdateRelation
|
||||
}: Pick<
|
||||
KnowledgeWorkspaceProps,
|
||||
| 'evidence'
|
||||
| 'graphNodes'
|
||||
| 'graphRelations'
|
||||
| 'onCreateEntity'
|
||||
| 'onCreateRelation'
|
||||
| 'onDeleteEntity'
|
||||
| 'onDeleteRelation'
|
||||
| 'onMergeEntities'
|
||||
| 'onMoveNode'
|
||||
| 'onOpenEvidence'
|
||||
| 'onReextractGraph'
|
||||
| 'onUpdateEntity'
|
||||
| 'onUpdateRelation'
|
||||
> & {
|
||||
libraryId: string
|
||||
ontology: KnowledgeOntologySettings
|
||||
}): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
|
||||
const ontologyDisplay = useMemo(
|
||||
() =>
|
||||
getKnowledgeOntologyDisplayDefinitions(
|
||||
ontology,
|
||||
locale === 'zh-CN' ? 'zh' : 'en'
|
||||
),
|
||||
[locale, ontology]
|
||||
)
|
||||
const entityTypeLabel = useCallback(
|
||||
(type: string) => {
|
||||
const canonical = normalizeEntityTypeAlias(type, ontology)
|
||||
const definition = ontologyDisplay.entityTypes.find(
|
||||
(candidate) => candidate.id === canonical
|
||||
)
|
||||
return definition ? `${definition.label} (${definition.id})` : type
|
||||
},
|
||||
[ontology, ontologyDisplay.entityTypes]
|
||||
)
|
||||
const relationTypeLabel = useCallback(
|
||||
(type: string) => {
|
||||
const canonical = normalizeRelationTypeAlias(type, ontology)
|
||||
const definition = ontologyDisplay.relationTypes.find(
|
||||
(candidate) => candidate.id === canonical
|
||||
)
|
||||
return definition ? `${definition.label} (${definition.id})` : type
|
||||
},
|
||||
[ontology, ontologyDisplay.relationTypes]
|
||||
)
|
||||
const [query, setQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>()
|
||||
const [editingEntity, setEditingEntity] = useState(false)
|
||||
const [creatingEntity, setCreatingEntity] = useState(false)
|
||||
const [relationForm, setRelationForm] =
|
||||
useState<KnowledgeGraphRelation | 'new'>()
|
||||
const [mergeTargetId, setMergeTargetId] = useState('')
|
||||
const [destructiveAction, setDestructiveAction] =
|
||||
useState<GraphDestructiveAction>()
|
||||
const entityPickerRef = useRef<HTMLSelectElement>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [fitViewRequest, setFitViewRequest] = useState(0)
|
||||
const [sidebarTab, setSidebarTab] =
|
||||
useState<GraphSidebarTab>('topology')
|
||||
const [reextracting, setReextracting] = useState(false)
|
||||
const [reextractError, setReextractError] = useState<string>()
|
||||
|
||||
const nodeMap = useMemo(
|
||||
() => new Map(graphNodes.map((node) => [node.id, node])),
|
||||
[graphNodes]
|
||||
)
|
||||
const types = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
graphNodes.map((node) =>
|
||||
normalizeEntityTypeAlias(node.type, ontology)
|
||||
)
|
||||
)
|
||||
).sort((left, right) =>
|
||||
entityTypeLabel(left).localeCompare(entityTypeLabel(right), locale)
|
||||
),
|
||||
[entityTypeLabel, graphNodes, locale, ontology]
|
||||
)
|
||||
const visibleNodes = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase(locale)
|
||||
const typeFilteredNodes = graphNodes.filter(
|
||||
(node) =>
|
||||
typeFilter === 'all' ||
|
||||
normalizeEntityTypeAlias(node.type, ontology) === typeFilter
|
||||
)
|
||||
if (!normalized) {
|
||||
return typeFilteredNodes
|
||||
}
|
||||
const matchedIds = new Set(
|
||||
typeFilteredNodes
|
||||
.filter((node) =>
|
||||
`${node.label} ${node.type} ${node.description ?? ''} ${(node.aliases ?? []).join(' ')}`
|
||||
.toLocaleLowerCase(locale)
|
||||
.includes(normalized)
|
||||
)
|
||||
.map((node) => node.id)
|
||||
)
|
||||
const visibleIds = new Set(matchedIds)
|
||||
for (const relation of graphRelations) {
|
||||
if (matchedIds.has(relation.sourceId)) {
|
||||
visibleIds.add(relation.targetId)
|
||||
}
|
||||
if (matchedIds.has(relation.targetId)) {
|
||||
visibleIds.add(relation.sourceId)
|
||||
}
|
||||
}
|
||||
return typeFilteredNodes.filter((node) => visibleIds.has(node.id))
|
||||
}, [
|
||||
graphNodes,
|
||||
graphRelations,
|
||||
locale,
|
||||
ontology,
|
||||
query,
|
||||
typeFilter
|
||||
])
|
||||
const visibleIds = useMemo(
|
||||
() => new Set(visibleNodes.map((node) => node.id)),
|
||||
[visibleNodes]
|
||||
)
|
||||
const visibleRelations = useMemo(
|
||||
() =>
|
||||
graphRelations.filter(
|
||||
(relation) =>
|
||||
visibleIds.has(relation.sourceId) &&
|
||||
visibleIds.has(relation.targetId)
|
||||
),
|
||||
[graphRelations, visibleIds]
|
||||
)
|
||||
const selectedNode = selectedNodeId
|
||||
? nodeMap.get(selectedNodeId)
|
||||
: undefined
|
||||
const relatedRelations = selectedNode
|
||||
? graphRelations.filter(
|
||||
(relation) =>
|
||||
relation.sourceId === selectedNode.id ||
|
||||
relation.targetId === selectedNode.id
|
||||
)
|
||||
: []
|
||||
const selectedEvidenceIds = new Set([
|
||||
...(selectedNode?.evidenceIds ?? []),
|
||||
...relatedRelations.flatMap((relation) => relation.evidenceIds ?? [])
|
||||
])
|
||||
const selectedEvidence = evidence.filter((item) =>
|
||||
selectedEvidenceIds.has(item.id)
|
||||
)
|
||||
|
||||
const selectNode = (nodeId: string): void => {
|
||||
setSelectedNodeId(nodeId)
|
||||
setSidebarTab('details')
|
||||
setCreatingEntity(false)
|
||||
setEditingEntity(false)
|
||||
setRelationForm(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="knowledge-graph">
|
||||
<section
|
||||
aria-label={t('graph.canvasAriaLabel')}
|
||||
className="knowledge-graph__canvas"
|
||||
>
|
||||
<div
|
||||
className="knowledge-graph__toolbar"
|
||||
>
|
||||
<label className="knowledge-graph__search">
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
size={15}
|
||||
/>
|
||||
<input
|
||||
aria-label={t('graph.searchAriaLabel')}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t('graph.searchPlaceholder')}
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
aria-label={t('graph.typeFilterAriaLabel')}
|
||||
className="knowledge-graph__filter"
|
||||
onChange={(event) => setTypeFilter(event.currentTarget.value)}
|
||||
value={typeFilter}
|
||||
>
|
||||
<option value="all">{t('graph.allTypes')}</option>
|
||||
{types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{entityTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label={t('graph.entityPickerAriaLabel')}
|
||||
className="knowledge-graph__entity-picker"
|
||||
onChange={(event) => {
|
||||
if (event.currentTarget.value) {
|
||||
selectNode(event.currentTarget.value)
|
||||
}
|
||||
}}
|
||||
ref={entityPickerRef}
|
||||
value={
|
||||
selectedNodeId && visibleIds.has(selectedNodeId)
|
||||
? selectedNodeId
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<option value="">{t('graph.selectEntity')}</option>
|
||||
{visibleNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{node.label} · {entityTypeLabel(node.type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={reextracting}
|
||||
onClick={() => {
|
||||
setReextracting(true)
|
||||
setReextractError(undefined)
|
||||
void Promise.resolve(onReextractGraph(libraryId))
|
||||
.catch((reason) =>
|
||||
setReextractError(toErrorMessage(reason, t))
|
||||
)
|
||||
.finally(() => setReextracting(false))
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
{reextracting
|
||||
? t('actions.reextracting')
|
||||
: t('actions.reextract')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
setSelectedNodeId(undefined)
|
||||
setCreatingEntity(true)
|
||||
setSidebarTab('details')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={15} />
|
||||
{t('actions.addEntity')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('graph.zoomOutAriaLabel')}
|
||||
className="knowledge-graph__icon-button secondary-button"
|
||||
disabled={zoom <= 0.5}
|
||||
onClick={() =>
|
||||
setZoom((current) => Math.max(0.5, current - 0.15))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ZoomOut aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="knowledge-graph__zoom"
|
||||
>
|
||||
{formatPercent(zoom, locale)}
|
||||
</span>
|
||||
<button
|
||||
aria-label={t('graph.zoomInAriaLabel')}
|
||||
className="knowledge-graph__icon-button secondary-button"
|
||||
disabled={zoom >= 2}
|
||||
onClick={() =>
|
||||
setZoom((current) => Math.min(2, current + 0.15))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ZoomIn aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setFitViewRequest((current) => current + 1)}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw aria-hidden="true" size={15} />
|
||||
{t('graph.fitView')}
|
||||
</button>
|
||||
<InlineHelp label={t('graph.canvasAriaLabel')}>{t('graph.interactionHint')}</InlineHelp>
|
||||
{reextractError && (
|
||||
<span
|
||||
className="knowledge-graph__toolbar-error"
|
||||
role="alert"
|
||||
>
|
||||
{reextractError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{graphNodes.length === 0 ? (
|
||||
<div className="knowledge-graph__empty">
|
||||
<div>
|
||||
<Network aria-hidden="true" size={30} />
|
||||
<p>{t('graph.empty')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<KnowledgeGraphChartLoader
|
||||
fitViewRequest={fitViewRequest}
|
||||
nodes={visibleNodes}
|
||||
onMoveNode={onMoveNode}
|
||||
onSelectNode={selectNode}
|
||||
onZoomChange={setZoom}
|
||||
relations={visibleRelations}
|
||||
selectedNodeId={selectedNodeId}
|
||||
zoom={zoom}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{sidebarTab === 'topology' && (
|
||||
<aside
|
||||
aria-label={t('graph.topologyAriaLabel')}
|
||||
className="knowledge-graph__detail"
|
||||
>
|
||||
<GraphSidebarNavigation
|
||||
onChange={setSidebarTab}
|
||||
relationCount={visibleRelations.length}
|
||||
value={sidebarTab}
|
||||
/>
|
||||
<section
|
||||
aria-labelledby="knowledge-graph-sidebar-tab-topology"
|
||||
className="knowledge-graph__detail-panel"
|
||||
id="knowledge-graph-sidebar-panel-topology"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div className="knowledge-graph__panel-heading">
|
||||
<div>
|
||||
<h3>{t('graph.visibleRelations.title')}</h3>
|
||||
<p>{t('graph.visibleRelations.description')}</p>
|
||||
</div>
|
||||
<span>
|
||||
{t('graph.visibleRelations.count', {
|
||||
count: formatNumber(visibleRelations.length, locale)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{visibleRelations.length === 0 ? (
|
||||
<p className="knowledge-graph__panel-empty">
|
||||
{t('graph.visibleRelations.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
aria-label={t('graph.visibleRelations.listAriaLabel')}
|
||||
className="knowledge-graph__topology-list"
|
||||
>
|
||||
{visibleRelations.map((relation) => (
|
||||
<li
|
||||
className="knowledge-graph__relation-card"
|
||||
key={relation.id}
|
||||
>
|
||||
<GraphRelationPath
|
||||
nodeMap={nodeMap}
|
||||
onSelectNode={selectNode}
|
||||
relation={relation}
|
||||
relationLabel={relationTypeLabel}
|
||||
/>
|
||||
{relation.description && (
|
||||
<p>{relation.description}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{sidebarTab === 'details' && creatingEntity && (
|
||||
<aside
|
||||
aria-label={t('graph.addEntityPanelAriaLabel')}
|
||||
className="knowledge-graph__detail"
|
||||
>
|
||||
<GraphSidebarNavigation
|
||||
onChange={setSidebarTab}
|
||||
relationCount={visibleRelations.length}
|
||||
value={sidebarTab}
|
||||
/>
|
||||
<section
|
||||
aria-labelledby="knowledge-graph-sidebar-tab-details"
|
||||
className="knowledge-graph__detail-panel"
|
||||
id="knowledge-graph-sidebar-panel-details"
|
||||
role="tabpanel"
|
||||
>
|
||||
<h3>{t('actions.addEntity')}</h3>
|
||||
<EntityEditor
|
||||
ontology={ontology}
|
||||
onCancel={() => {
|
||||
setCreatingEntity(false)
|
||||
setSidebarTab('topology')
|
||||
}}
|
||||
onSave={async (input) => {
|
||||
await onCreateEntity(input)
|
||||
setCreatingEntity(false)
|
||||
setSidebarTab('topology')
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{sidebarTab === 'details' && selectedNode && (
|
||||
<aside
|
||||
aria-label={t('graph.entityDetailsAriaLabel')}
|
||||
className="knowledge-graph__detail"
|
||||
>
|
||||
<GraphSidebarNavigation
|
||||
onChange={setSidebarTab}
|
||||
relationCount={visibleRelations.length}
|
||||
value={sidebarTab}
|
||||
/>
|
||||
<section
|
||||
aria-labelledby="knowledge-graph-sidebar-tab-details"
|
||||
className="knowledge-graph__detail-panel"
|
||||
id="knowledge-graph-sidebar-panel-details"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div className="knowledge-graph__entity-heading">
|
||||
<div>
|
||||
<span>
|
||||
{entityTypeLabel(selectedNode.type)}
|
||||
</span>
|
||||
<h3>{selectedNode.label}</h3>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('graph.closeEntityDetailsAriaLabel')}
|
||||
className="knowledge-graph__icon-button secondary-button"
|
||||
onClick={() => {
|
||||
setSelectedNodeId(undefined)
|
||||
setSidebarTab('topology')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingEntity ? (
|
||||
<div className="knowledge-graph__entity-editor">
|
||||
<EntityEditor
|
||||
node={selectedNode}
|
||||
ontology={ontology}
|
||||
onCancel={() => setEditingEntity(false)}
|
||||
onSave={async (update) => {
|
||||
await onUpdateEntity(selectedNode.id, update)
|
||||
setEditingEntity(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="knowledge-muted">
|
||||
{selectedNode.description || t('graph.noEntityDescription')}
|
||||
</p>
|
||||
{(selectedNode.aliases?.length ?? 0) > 0 && (
|
||||
<div className="knowledge-graph__aliases">
|
||||
{t('graph.aliases', {
|
||||
aliases: selectedNode.aliases?.join(
|
||||
t('format.listSeparator')
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="knowledge-graph__entity-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setEditingEntity(true)}
|
||||
type="button"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
{t('actions.edit')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('graph.deleteEntityAriaLabel', {
|
||||
name: selectedNode.label
|
||||
})}
|
||||
className="danger-button danger-button--quiet"
|
||||
onClick={() =>
|
||||
setDestructiveAction({
|
||||
kind: 'delete-entity',
|
||||
node: selectedNode,
|
||||
relationCount: relatedRelations.length
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
{t('actions.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr className="knowledge-graph__divider" />
|
||||
<div
|
||||
className="knowledge-graph__section-heading"
|
||||
>
|
||||
<strong>{t('graph.relations')}</strong>
|
||||
<button
|
||||
className="knowledge-graph__compact-button secondary-button"
|
||||
onClick={() => setRelationForm('new')}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={14} />
|
||||
{t('actions.add')}
|
||||
</button>
|
||||
</div>
|
||||
{relationForm && (
|
||||
<RelationForm
|
||||
nodes={graphNodes}
|
||||
ontology={ontology}
|
||||
onCancel={() => setRelationForm(undefined)}
|
||||
onSave={async (input) => {
|
||||
if (relationForm === 'new') {
|
||||
await onCreateRelation(input)
|
||||
} else {
|
||||
await onUpdateRelation(relationForm.id, input)
|
||||
}
|
||||
setRelationForm(undefined)
|
||||
}}
|
||||
relation={
|
||||
relationForm === 'new' ? undefined : relationForm
|
||||
}
|
||||
sourceId={selectedNode.id}
|
||||
/>
|
||||
)}
|
||||
<ul
|
||||
className="knowledge-graph__entity-relations"
|
||||
>
|
||||
{relatedRelations.map((relation) => {
|
||||
return (
|
||||
<li
|
||||
className="knowledge-graph__relation-card"
|
||||
key={relation.id}
|
||||
>
|
||||
<GraphRelationPath
|
||||
nodeMap={nodeMap}
|
||||
onSelectNode={selectNode}
|
||||
relation={relation}
|
||||
relationLabel={relationTypeLabel}
|
||||
/>
|
||||
{relation.description && (
|
||||
<p>{relation.description}</p>
|
||||
)}
|
||||
<div className="knowledge-graph__relation-actions">
|
||||
<button
|
||||
aria-label={t('graph.editRelationAriaLabel', {
|
||||
type: relation.type
|
||||
})}
|
||||
className="knowledge-graph__compact-button secondary-button"
|
||||
onClick={() => setRelationForm(relation)}
|
||||
type="button"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={13} />
|
||||
{t('actions.edit')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('graph.deleteRelationAriaLabel', {
|
||||
type: relation.type
|
||||
})}
|
||||
className="knowledge-graph__compact-button danger-button danger-button--quiet"
|
||||
onClick={() =>
|
||||
setDestructiveAction({
|
||||
kind: 'delete-relation',
|
||||
relation,
|
||||
sourceLabel:
|
||||
nodeMap.get(relation.sourceId)?.label ??
|
||||
t('graph.unknownEntity'),
|
||||
targetLabel:
|
||||
nodeMap.get(relation.targetId)?.label ??
|
||||
t('graph.unknownEntity'),
|
||||
typeLabel: relationTypeLabel(relation.type)
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
{t('actions.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<strong>{t('graph.merge.title')}</strong>
|
||||
<div className="knowledge-graph__merge">
|
||||
<select
|
||||
aria-label={t('graph.merge.targetAriaLabel')}
|
||||
onChange={(event) => setMergeTargetId(event.currentTarget.value)}
|
||||
value={mergeTargetId}
|
||||
>
|
||||
<option value="">{t('graph.merge.targetPlaceholder')}</option>
|
||||
{graphNodes
|
||||
.filter((node) => node.id !== selectedNode.id)
|
||||
.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{node.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
aria-label={t('graph.merge.actionAriaLabel')}
|
||||
className="knowledge-graph__icon-button secondary-button"
|
||||
disabled={!mergeTargetId}
|
||||
onClick={() => {
|
||||
const mergeTarget = nodeMap.get(mergeTargetId)
|
||||
if (mergeTarget) {
|
||||
setDestructiveAction({
|
||||
kind: 'merge-entities',
|
||||
source: selectedNode,
|
||||
target: mergeTarget
|
||||
})
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<GitMerge aria-hidden="true" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className="knowledge-graph__divider" />
|
||||
<strong>
|
||||
{t('graph.evidence.title', {
|
||||
count: formatNumber(selectedEvidence.length, locale)
|
||||
})}
|
||||
</strong>
|
||||
{selectedEvidence.length === 0 ? (
|
||||
<p className="knowledge-muted">{t('graph.evidence.empty')}</p>
|
||||
) : (
|
||||
<ol className="knowledge-graph__evidence-list">
|
||||
{selectedEvidence.map((item) => (
|
||||
<li key={item.id}>
|
||||
{onOpenEvidence ? (
|
||||
<button
|
||||
className="knowledge-graph__evidence-button"
|
||||
onClick={() => onOpenEvidence(item)}
|
||||
type="button"
|
||||
>
|
||||
<strong>
|
||||
{item.documentName}
|
||||
</strong>
|
||||
{item.location && (
|
||||
<span className="knowledge-graph__evidence-location">
|
||||
{item.location}
|
||||
</span>
|
||||
)}
|
||||
<span className="knowledge-graph__evidence-excerpt">
|
||||
{item.excerpt}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<strong>
|
||||
{item.documentName}
|
||||
</strong>
|
||||
{item.location && (
|
||||
<span className="knowledge-graph__evidence-location">
|
||||
{item.location}
|
||||
</span>
|
||||
)}
|
||||
<span className="knowledge-graph__evidence-excerpt">
|
||||
{item.excerpt}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{sidebarTab === 'details' && !selectedNode && !creatingEntity && (
|
||||
<aside
|
||||
aria-label={t('graph.detailsAriaLabel')}
|
||||
className="knowledge-graph__detail"
|
||||
>
|
||||
<GraphSidebarNavigation
|
||||
onChange={setSidebarTab}
|
||||
relationCount={visibleRelations.length}
|
||||
value={sidebarTab}
|
||||
/>
|
||||
<section
|
||||
aria-labelledby="knowledge-graph-sidebar-tab-details"
|
||||
className="knowledge-graph__detail-panel"
|
||||
id="knowledge-graph-sidebar-panel-details"
|
||||
role="tabpanel"
|
||||
>
|
||||
<p className="knowledge-graph__panel-empty">
|
||||
{t('graph.detailsPrompt')}
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
)}
|
||||
{destructiveAction && (
|
||||
<GraphDestructiveDialog
|
||||
action={destructiveAction}
|
||||
onCancel={() => setDestructiveAction(undefined)}
|
||||
onConfirm={async () => {
|
||||
if (destructiveAction.kind === 'delete-entity') {
|
||||
await onDeleteEntity(destructiveAction.node.id)
|
||||
setSelectedNodeId(undefined)
|
||||
setSidebarTab('topology')
|
||||
requestAnimationFrame(() =>
|
||||
entityPickerRef.current?.focus()
|
||||
)
|
||||
return
|
||||
}
|
||||
if (destructiveAction.kind === 'delete-relation') {
|
||||
await onDeleteRelation(destructiveAction.relation.id)
|
||||
requestAnimationFrame(() =>
|
||||
entityPickerRef.current?.focus()
|
||||
)
|
||||
return
|
||||
}
|
||||
await onMergeEntities(
|
||||
destructiveAction.source.id,
|
||||
destructiveAction.target.id
|
||||
)
|
||||
setMergeTargetId('')
|
||||
setSelectedNodeId(destructiveAction.target.id)
|
||||
requestAnimationFrame(() =>
|
||||
entityPickerRef.current?.focus()
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createPreloadableComponent } from '../preloadable-component'
|
||||
import { Component, type ReactNode, useState, Suspense } from 'react'
|
||||
import { AlertCircle, LoaderCircle } from 'lucide-react'
|
||||
import type { KnowledgeGraphChartProps } from '../KnowledgeGraphChart'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type KnowledgeGraphChartModule = typeof import('../KnowledgeGraphChart')
|
||||
|
||||
type KnowledgeGraphChartModuleLoader = (
|
||||
) => Promise<KnowledgeGraphChartModule>
|
||||
|
||||
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 (
|
||||
<div className="route-load-error" role="alert">
|
||||
<AlertCircle aria-hidden="true" size={20} />
|
||||
<strong>{this.props.errorMessage}</strong>
|
||||
<button onClick={this.props.onRetry} type="button">
|
||||
{this.props.retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<KnowledgeGraphChunkErrorBoundary
|
||||
errorMessage={t('graph.chunk.loadFailed')}
|
||||
key={loaderState.generation}
|
||||
onRetry={() =>
|
||||
setLoaderState((current) => ({
|
||||
generation: current.generation + 1,
|
||||
route: createKnowledgeGraphChartRoute(loadModule)
|
||||
}))
|
||||
}
|
||||
retryLabel={t('actions.retry')}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="route-loading-status"
|
||||
role="status"
|
||||
>
|
||||
<LoaderCircle aria-hidden="true" size={20} />
|
||||
<span>{t('graph.chunk.loading')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Chart {...props} />
|
||||
</Suspense>
|
||||
</KnowledgeGraphChunkErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
import type { KnowledgeLibrary, KnowledgeWorkspaceProps, KnowledgeGraphStrategy } from './types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { KnowledgeEmbeddingIndexSnapshot } from '../../../shared/embedding-contracts'
|
||||
import { defaultKnowledgeChunkingSettings, type KnowledgeChunkingSettings } from '../../../shared/knowledge-contracts'
|
||||
import {
|
||||
type KnowledgeOntologySettings,
|
||||
defaultKnowledgeOntologySettings,
|
||||
knowledgeOntologySettingsSchema
|
||||
} from '../../../shared/knowledge-ontology'
|
||||
import { toErrorMessage, strategyLabelKeys, parseAliases } from './helpers'
|
||||
import { InlineHelp } from '../InlineHelp'
|
||||
import { Search, RefreshCw } from 'lucide-react'
|
||||
import { KnowledgeEmbeddingIndexSection } from '../KnowledgeEmbeddingIndexSection'
|
||||
|
||||
export function KnowledgeSettingsView({
|
||||
library,
|
||||
mode,
|
||||
onCancelRebuild,
|
||||
onGetEmbeddingIndex,
|
||||
onOpenModelSettings,
|
||||
onOpenRetrieval,
|
||||
onRebuildLibrary,
|
||||
onRebuildEmbeddingIndex,
|
||||
onViewTasks,
|
||||
onUpdateKnowledgeSettings,
|
||||
onUpdateLibrary
|
||||
}: {
|
||||
library: KnowledgeLibrary
|
||||
mode: 'index' | 'graph'
|
||||
onCancelRebuild: KnowledgeWorkspaceProps['onCancelRebuild']
|
||||
onGetEmbeddingIndex: KnowledgeWorkspaceProps['onGetEmbeddingIndex']
|
||||
onOpenModelSettings: KnowledgeWorkspaceProps['onOpenModelSettings']
|
||||
onOpenRetrieval: () => void
|
||||
onRebuildLibrary: KnowledgeWorkspaceProps['onRebuildLibrary']
|
||||
onRebuildEmbeddingIndex:
|
||||
KnowledgeWorkspaceProps['onRebuildEmbeddingIndex']
|
||||
onViewTasks: () => void
|
||||
onUpdateKnowledgeSettings:
|
||||
KnowledgeWorkspaceProps['onUpdateKnowledgeSettings']
|
||||
onUpdateLibrary: KnowledgeWorkspaceProps['onUpdateLibrary']
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [rebuilding, setRebuilding] = useState(false)
|
||||
const [embeddingIndexLoading, setEmbeddingIndexLoading] =
|
||||
useState(true)
|
||||
const [embeddingIndex, setEmbeddingIndex] =
|
||||
useState<KnowledgeEmbeddingIndexSnapshot>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [chunking, setChunking] = useState(
|
||||
library.chunkingSettings ?? defaultKnowledgeChunkingSettings
|
||||
)
|
||||
const [ontology, setOntology] = useState<KnowledgeOntologySettings>(
|
||||
library.ontologySettings ?? defaultKnowledgeOntologySettings
|
||||
)
|
||||
const [ontologyError, setOntologyError] = useState<string>()
|
||||
|
||||
const requestEmbeddingIndex = useCallback(
|
||||
() => Promise.resolve(onGetEmbeddingIndex(library.id)),
|
||||
[library.id, onGetEmbeddingIndex]
|
||||
)
|
||||
const updateEmbeddingIndex = useCallback(
|
||||
(snapshot: KnowledgeEmbeddingIndexSnapshot): void => {
|
||||
setEmbeddingIndex((current) => {
|
||||
const currentJob = current?.indexStatus.job
|
||||
const nextJob = snapshot.indexStatus.job
|
||||
return current &&
|
||||
current.knowledgeBaseId === snapshot.knowledgeBaseId &&
|
||||
current.enabled === snapshot.enabled &&
|
||||
current.configuration?.provider ===
|
||||
snapshot.configuration?.provider &&
|
||||
current.configuration?.model === snapshot.configuration?.model &&
|
||||
current.configuration?.endpoint ===
|
||||
snapshot.configuration?.endpoint &&
|
||||
current.configuration?.credentialConfigured ===
|
||||
snapshot.configuration?.credentialConfigured &&
|
||||
current.coverage.total === snapshot.coverage.total &&
|
||||
current.coverage.indexed === snapshot.coverage.indexed &&
|
||||
current.coverage.missing === snapshot.coverage.missing &&
|
||||
current.coverage.error === snapshot.coverage.error &&
|
||||
currentJob?.id === nextJob?.id &&
|
||||
currentJob?.status === nextJob?.status &&
|
||||
currentJob?.progress.completed ===
|
||||
nextJob?.progress.completed &&
|
||||
currentJob?.progress.total === nextJob?.progress.total &&
|
||||
currentJob?.progress.percent === nextJob?.progress.percent &&
|
||||
currentJob?.completedAt === nextJob?.completedAt &&
|
||||
currentJob?.error?.code === nextJob?.error?.code &&
|
||||
currentJob?.error?.message === nextJob?.error?.message &&
|
||||
currentJob?.error?.remedy === nextJob?.error?.remedy
|
||||
? current
|
||||
: snapshot
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'index') {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
void requestEmbeddingIndex()
|
||||
.then((snapshot) => {
|
||||
if (active && snapshot.knowledgeBaseId === library.id) {
|
||||
updateEmbeddingIndex(snapshot)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (active) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setEmbeddingIndexLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [library.id, mode, requestEmbeddingIndex, t, updateEmbeddingIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'index') {
|
||||
return
|
||||
}
|
||||
const active =
|
||||
embeddingIndex?.indexStatus.job?.status === 'queued' ||
|
||||
embeddingIndex?.indexStatus.job?.status === 'running'
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
let mounted = true
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
const poll = async (): Promise<void> => {
|
||||
void requestEmbeddingIndex()
|
||||
.then((snapshot) => {
|
||||
if (!mounted || snapshot.knowledgeBaseId !== library.id) {
|
||||
return
|
||||
}
|
||||
updateEmbeddingIndex(snapshot)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
timeout = setTimeout(() => {
|
||||
void poll()
|
||||
}, 350)
|
||||
}
|
||||
})
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
void poll()
|
||||
}, 350)
|
||||
return () => {
|
||||
mounted = false
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
embeddingIndex?.indexStatus.job?.status,
|
||||
library.id,
|
||||
mode,
|
||||
requestEmbeddingIndex,
|
||||
updateEmbeddingIndex
|
||||
])
|
||||
|
||||
const update = async (
|
||||
change: Parameters<KnowledgeWorkspaceProps['onUpdateLibrary']>[1]
|
||||
): Promise<void> => {
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onUpdateLibrary(library.id, change)
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveChunking = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onUpdateKnowledgeSettings(library.id, { chunking })
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveOntology = async (): Promise<void> => {
|
||||
const parsed = knowledgeOntologySettingsSchema.safeParse(ontology)
|
||||
if (!parsed.success) {
|
||||
setOntologyError(t('settings.ontology.validation'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
setOntologyError(undefined)
|
||||
try {
|
||||
await onUpdateKnowledgeSettings(library.id, {
|
||||
ontology: parsed.data
|
||||
})
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const rebuildLibrary = async (): Promise<void> => {
|
||||
setRebuilding(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onRebuildLibrary(library.id)
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setRebuilding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRebuild = async (): Promise<void> => {
|
||||
setError(undefined)
|
||||
try {
|
||||
await onCancelRebuild(library.id)
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
}
|
||||
}
|
||||
|
||||
const rebuildEmbeddingIndex = async (): Promise<void> => {
|
||||
setError(undefined)
|
||||
try {
|
||||
setEmbeddingIndex(
|
||||
await onRebuildEmbeddingIndex(library.id)
|
||||
)
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`knowledge-settings knowledge-settings--${mode}`}
|
||||
>
|
||||
{mode === 'index' && (
|
||||
<section className="knowledge-settings__section">
|
||||
<div className="inline-help-label">
|
||||
<h3>{t('settings.retrieval.title')}</h3>
|
||||
<InlineHelp label={t('settings.retrieval.title')}>
|
||||
{t('settings.retrieval.description')}
|
||||
</InlineHelp>
|
||||
</div>
|
||||
<div className="knowledge-settings__actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={onOpenRetrieval}
|
||||
type="button"
|
||||
>
|
||||
<Search aria-hidden="true" size={15} />
|
||||
{t('settings.retrieval.action')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{mode === 'index' && (
|
||||
<KnowledgeEmbeddingIndexSection
|
||||
loading={embeddingIndexLoading}
|
||||
onGoToSettings={onOpenModelSettings}
|
||||
onRebuild={() => void rebuildEmbeddingIndex()}
|
||||
onViewTasks={onViewTasks}
|
||||
snapshot={embeddingIndex}
|
||||
/>
|
||||
)}
|
||||
{mode === 'index' && (
|
||||
<section
|
||||
className="knowledge-settings__section"
|
||||
>
|
||||
<label
|
||||
className={`knowledge-settings__toggle toggle-row${
|
||||
saving ? ' knowledge-settings__toggle--pending' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
checked={library.graphEnabled}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void update({ graphEnabled: event.currentTarget.checked })
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<strong>{t('graph.enable')}</strong>
|
||||
<span className="knowledge-muted">
|
||||
{t('settings.enableDescription')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
{mode === 'graph' && (
|
||||
<section
|
||||
aria-labelledby="knowledge-graph-settings-title"
|
||||
className="knowledge-settings__section"
|
||||
>
|
||||
<div>
|
||||
<h3 id="knowledge-graph-settings-title">
|
||||
{t('settings.graphConfiguration.title')}
|
||||
</h3>
|
||||
<p className="knowledge-section-description">
|
||||
{t('settings.graphConfiguration.description')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.graphExtractionStrategy')}
|
||||
<select
|
||||
aria-label={t('settings.strategyAriaLabel')}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void update({
|
||||
graphStrategy:
|
||||
event.currentTarget.value as KnowledgeGraphStrategy
|
||||
})
|
||||
}
|
||||
value={library.graphStrategy}
|
||||
>
|
||||
{Object.entries(strategyLabelKeys).map(([value, key]) => (
|
||||
<option key={value} value={value}>
|
||||
{t(key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="knowledge-muted">
|
||||
{t('settings.askDescription')}
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
{mode === 'index' && (
|
||||
<section
|
||||
aria-labelledby="knowledge-chunking-settings-title"
|
||||
className="knowledge-settings__section"
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
id="knowledge-chunking-settings-title"
|
||||
>
|
||||
{t('settings.chunking.title')}
|
||||
</h3>
|
||||
<p className="knowledge-section-description">
|
||||
{t('settings.chunking.description')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="knowledge-field">
|
||||
{t('settings.chunking.mode')}
|
||||
<select
|
||||
disabled={saving || rebuilding}
|
||||
onChange={(event) =>
|
||||
setChunking((current) => ({
|
||||
...current,
|
||||
mode: event.currentTarget
|
||||
.value as KnowledgeChunkingSettings['mode']
|
||||
}))
|
||||
}
|
||||
value={chunking.mode}
|
||||
>
|
||||
<option value="fixed">
|
||||
{t('settings.chunking.modes.fixed')}
|
||||
</option>
|
||||
<option value="structure">
|
||||
{t('settings.chunking.modes.structure')}
|
||||
</option>
|
||||
<option value="parent-child">
|
||||
{t('settings.chunking.modes.parentChild')}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="toggle-row">
|
||||
<span>
|
||||
<strong>{t('settings.chunking.contextualIndexing')}</strong>
|
||||
<small>
|
||||
{t('settings.chunking.contextualIndexingDescription')}
|
||||
</small>
|
||||
</span>
|
||||
<input
|
||||
checked={chunking.contextualIndexingEnabled}
|
||||
disabled={saving || rebuilding}
|
||||
onChange={(event) =>
|
||||
setChunking((current) => ({
|
||||
...current,
|
||||
contextualIndexingEnabled: event.currentTarget.checked
|
||||
}))
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
<div className="knowledge-settings__chunking-grid">
|
||||
<label className="knowledge-field">
|
||||
{t('settings.chunking.targetCharacters')}
|
||||
<input
|
||||
disabled={saving || rebuilding}
|
||||
max={8_000}
|
||||
min={400}
|
||||
onChange={(event) =>
|
||||
setChunking((current) => ({
|
||||
...current,
|
||||
targetCharacters: Number(event.currentTarget.value)
|
||||
}))
|
||||
}
|
||||
type="number"
|
||||
value={chunking.targetCharacters}
|
||||
/>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('settings.chunking.overlapCharacters')}
|
||||
<input
|
||||
disabled={saving || rebuilding}
|
||||
max={3_200}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
setChunking((current) => ({
|
||||
...current,
|
||||
overlapCharacters: Number(event.currentTarget.value)
|
||||
}))
|
||||
}
|
||||
type="number"
|
||||
value={chunking.overlapCharacters}
|
||||
/>
|
||||
</label>
|
||||
{chunking.mode === 'parent-child' && (
|
||||
<>
|
||||
<label className="knowledge-field">
|
||||
{t('settings.chunking.parentCharacters')}
|
||||
<input
|
||||
disabled={saving || rebuilding}
|
||||
max={16_000}
|
||||
min={1_600}
|
||||
onChange={(event) =>
|
||||
setChunking((current) => ({
|
||||
...current,
|
||||
parentCharacters: Number(
|
||||
event.currentTarget.value
|
||||
)
|
||||
}))
|
||||
}
|
||||
type="number"
|
||||
value={chunking.parentCharacters}
|
||||
/>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('settings.chunking.childCharacters')}
|
||||
<input
|
||||
disabled={saving || rebuilding}
|
||||
max={4_000}
|
||||
min={300}
|
||||
onChange={(event) =>
|
||||
setChunking((current) => ({
|
||||
...current,
|
||||
childCharacters: Number(
|
||||
event.currentTarget.value
|
||||
)
|
||||
}))
|
||||
}
|
||||
type="number"
|
||||
value={chunking.childCharacters}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{library.chunkingRebuildRequired && (
|
||||
<p className="knowledge-settings__rebuild-note" role="status">
|
||||
{t('settings.chunking.rebuildRequired')}
|
||||
</p>
|
||||
)}
|
||||
<div className="knowledge-settings__actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving || rebuilding}
|
||||
onClick={() => void saveChunking()}
|
||||
type="button"
|
||||
>
|
||||
{saving
|
||||
? t('settings.chunking.saving')
|
||||
: t('settings.chunking.save')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void (
|
||||
rebuilding ? cancelRebuild() : rebuildLibrary()
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
{rebuilding
|
||||
? t('settings.chunking.cancelRebuild')
|
||||
: t('settings.chunking.rebuild')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{mode === 'graph' && (
|
||||
<section
|
||||
aria-labelledby="knowledge-ontology-settings-title"
|
||||
className="knowledge-settings__ontology knowledge-settings__section"
|
||||
>
|
||||
<div className="inline-help-label">
|
||||
<h3 id="knowledge-ontology-settings-title">
|
||||
{t('settings.ontology.title')}
|
||||
</h3>
|
||||
<InlineHelp label={t('settings.ontology.title')}>
|
||||
{t('settings.ontology.description')}
|
||||
</InlineHelp>
|
||||
</div>
|
||||
{library.ontologyRebuildRequired && (
|
||||
<p className="knowledge-settings__rebuild-note" role="status">
|
||||
{t('settings.ontology.rebuildRequired')}
|
||||
</p>
|
||||
)}
|
||||
<div className="knowledge-settings__definition-group">
|
||||
<h4>{t('settings.ontology.entityTypes')}</h4>
|
||||
{ontology.entityTypes.map((definition, index) => (
|
||||
<fieldset
|
||||
className="knowledge-settings__definition"
|
||||
key={`${definition.id}-${index}`}
|
||||
>
|
||||
<legend>{definition.id}</legend>
|
||||
<div className="knowledge-settings__definition-grid">
|
||||
<label>
|
||||
{t('settings.ontology.id')}
|
||||
<input
|
||||
disabled={saving || definition.id === 'CONCEPT'}
|
||||
maxLength={64}
|
||||
onChange={(event) => {
|
||||
const id = event.currentTarget.value
|
||||
.toLocaleUpperCase('en-US')
|
||||
.replace(/[^A-Z0-9_]/g, '')
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
entityTypes: current.entityTypes.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, id } : item
|
||||
),
|
||||
relationTypes: current.relationTypes.map((relation) => ({
|
||||
...relation,
|
||||
sourceTypes: relation.sourceTypes?.map((typeId) =>
|
||||
typeId === definition.id ? id : typeId
|
||||
),
|
||||
targetTypes: relation.targetTypes?.map((typeId) =>
|
||||
typeId === definition.id ? id : typeId
|
||||
)
|
||||
}))
|
||||
}))
|
||||
}}
|
||||
value={definition.id}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('settings.ontology.nameZh')}
|
||||
<input
|
||||
disabled={saving}
|
||||
maxLength={80}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
entityTypes: current.entityTypes.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? {
|
||||
...item,
|
||||
name: {
|
||||
...item.name,
|
||||
zh: value
|
||||
}
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
value={definition.name.zh}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('settings.ontology.nameEn')}
|
||||
<input
|
||||
disabled={saving}
|
||||
maxLength={80}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
entityTypes: current.entityTypes.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? {
|
||||
...item,
|
||||
name: {
|
||||
...item.name,
|
||||
en: value
|
||||
}
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
value={definition.name.en}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('settings.ontology.aliases')}
|
||||
<input
|
||||
defaultValue={definition.aliases.join(', ')}
|
||||
disabled={saving}
|
||||
maxLength={2592}
|
||||
onBlur={(event) => {
|
||||
const value = event.currentTarget.value
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
entityTypes: current.entityTypes.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? {
|
||||
...item,
|
||||
aliases: parseAliases(value, 32)
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
<div className="knowledge-settings__definition-group">
|
||||
<h4>{t('settings.ontology.relationTypes')}</h4>
|
||||
{ontology.relationTypes.map((definition, index) => (
|
||||
<fieldset
|
||||
className="knowledge-settings__definition"
|
||||
key={`${definition.id}-${index}`}
|
||||
>
|
||||
<legend>{definition.id}</legend>
|
||||
<div className="knowledge-settings__definition-grid">
|
||||
<label>
|
||||
{t('settings.ontology.id')}
|
||||
<input
|
||||
disabled={saving}
|
||||
maxLength={64}
|
||||
onChange={(event) => {
|
||||
const id = event.currentTarget.value
|
||||
.toLocaleUpperCase('en-US')
|
||||
.replace(/[^A-Z0-9_]/g, '')
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
relationTypes: current.relationTypes.map(
|
||||
(item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, id } : item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
value={definition.id}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('settings.ontology.nameZh')}
|
||||
<input
|
||||
disabled={saving}
|
||||
maxLength={80}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
relationTypes: current.relationTypes.map(
|
||||
(item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? {
|
||||
...item,
|
||||
name: {
|
||||
...item.name,
|
||||
zh: value
|
||||
}
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
value={definition.name.zh}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('settings.ontology.nameEn')}
|
||||
<input
|
||||
disabled={saving}
|
||||
maxLength={80}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
relationTypes: current.relationTypes.map(
|
||||
(item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? {
|
||||
...item,
|
||||
name: {
|
||||
...item.name,
|
||||
en: value
|
||||
}
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
value={definition.name.en}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('settings.ontology.aliases')}
|
||||
<input
|
||||
defaultValue={definition.aliases.join(', ')}
|
||||
disabled={saving}
|
||||
maxLength={2592}
|
||||
onBlur={(event) => {
|
||||
const value = event.currentTarget.value
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
relationTypes: current.relationTypes.map(
|
||||
(item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? {
|
||||
...item,
|
||||
aliases: parseAliases(value, 32)
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="knowledge-settings__endpoint-grid">
|
||||
{(['sourceTypes', 'targetTypes'] as const).map((field) => (
|
||||
<fieldset key={field}>
|
||||
<legend>
|
||||
{t(
|
||||
field === 'sourceTypes'
|
||||
? 'settings.ontology.sourceTypes'
|
||||
: 'settings.ontology.targetTypes'
|
||||
)}
|
||||
</legend>
|
||||
<label className="knowledge-settings__all-endpoints">
|
||||
<input
|
||||
checked={!definition[field]}
|
||||
disabled={saving}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
relationTypes: current.relationTypes.map(
|
||||
(item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? {
|
||||
...item,
|
||||
[field]: checked
|
||||
? undefined
|
||||
: [current.entityTypes[0]!.id]
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t('settings.ontology.anyEndpoint')}
|
||||
</label>
|
||||
{!definition[field] &&
|
||||
<span className="knowledge-settings__field-help">
|
||||
{t('settings.ontology.anyEndpointHelp')}
|
||||
</span>}
|
||||
{definition[field] &&
|
||||
ontology.entityTypes.map((entityType) => (
|
||||
<label key={entityType.id}>
|
||||
<input
|
||||
checked={definition[field]?.includes(
|
||||
entityType.id
|
||||
)}
|
||||
disabled={
|
||||
saving ||
|
||||
(definition[field]?.length === 1 &&
|
||||
definition[field]?.[0] === entityType.id)
|
||||
}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked
|
||||
setOntology((current) => ({
|
||||
...current,
|
||||
relationTypes: current.relationTypes.map(
|
||||
(item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? {
|
||||
...item,
|
||||
[field]: checked
|
||||
? [
|
||||
...(item[field] ?? []),
|
||||
entityType.id
|
||||
]
|
||||
: item[field]?.filter(
|
||||
(typeId) =>
|
||||
typeId !== entityType.id
|
||||
)
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{entityType.name.zh} / {entityType.name.en}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
{ontologyError && <p role="alert">{ontologyError}</p>}
|
||||
<div className="knowledge-settings__actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving || rebuilding}
|
||||
onClick={() => void saveOntology()}
|
||||
type="button"
|
||||
>
|
||||
{saving
|
||||
? t('settings.ontology.saving')
|
||||
: t('settings.ontology.save')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="knowledge-settings__field-help">
|
||||
{t('settings.ontology.noImplicitRebuild')}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
{error && (
|
||||
<p className="knowledge-inline-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import type { KnowledgeTaskItem, KnowledgeWorkspaceProps } from './types'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { getLocaleFormatters, resolvedLocale, formatNumber, formatPercent } from './formatting'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { toErrorMessage, taskStageLabelKeys, clampProgress } from './helpers'
|
||||
import { ChevronDown, ChevronRight, LoaderCircle, X, RotateCcw, ListChecks } from 'lucide-react'
|
||||
import { EmptyState, SegmentedControl } from '../WorkspacePrimitives'
|
||||
|
||||
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<KnowledgeTaskItem['kind'], string>
|
||||
|
||||
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<KnowledgeTaskItem['status'], string>
|
||||
|
||||
const taskScopeLabelKeys = {
|
||||
library: 'taskScopes.library',
|
||||
source: 'taskScopes.source',
|
||||
document: 'taskScopes.document'
|
||||
} as const satisfies Record<KnowledgeTaskItem['scope'], string>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type KnowledgeTaskFilter = 'all' | 'active' | 'failed' | 'history'
|
||||
|
||||
export type KnowledgeTaskContext = {
|
||||
documentId?: string
|
||||
sourceId?: string
|
||||
}
|
||||
|
||||
type KnowledgeTaskAction = 'cancel' | 'retry'
|
||||
|
||||
type KnowledgeTaskActionError = {
|
||||
action: KnowledgeTaskAction
|
||||
message: string
|
||||
}
|
||||
|
||||
export function KnowledgeTasksView({
|
||||
context,
|
||||
onCancelTask,
|
||||
onClearContext,
|
||||
onRetryTask,
|
||||
tasks
|
||||
}: {
|
||||
context?: KnowledgeTaskContext
|
||||
onCancelTask: KnowledgeWorkspaceProps['onCancelTask']
|
||||
onClearContext: () => void
|
||||
onRetryTask: KnowledgeWorkspaceProps['onRetryTask']
|
||||
tasks: readonly KnowledgeTaskItem[]
|
||||
}): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
|
||||
const [filter, setFilter] = useState<KnowledgeTaskFilter>('all')
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
tasks
|
||||
.filter(
|
||||
(task) =>
|
||||
task.status === 'queued' || task.status === 'running'
|
||||
)
|
||||
.map((task) => task.parentTaskId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
)
|
||||
const [pendingActions, setPendingActions] = useState<
|
||||
ReadonlyMap<string, KnowledgeTaskAction>
|
||||
>(() => new Map())
|
||||
const [actionErrors, setActionErrors] = useState<
|
||||
ReadonlyMap<string, KnowledgeTaskActionError>
|
||||
>(() => new Map())
|
||||
const activeCount = tasks.filter(
|
||||
(task) => task.status === 'queued' || task.status === 'running'
|
||||
).length
|
||||
const failedCount = tasks.filter(
|
||||
(task) =>
|
||||
task.status === 'failed' || task.status === 'interrupted'
|
||||
).length
|
||||
const historyCount = tasks.length - activeCount - failedCount
|
||||
const taskById = useMemo(
|
||||
() => new Map(tasks.map((task) => [task.id, task])),
|
||||
[tasks]
|
||||
)
|
||||
const childrenByParent = useMemo(() => {
|
||||
const result = new Map<string, KnowledgeTaskItem[]>()
|
||||
for (const task of tasks) {
|
||||
if (!task.parentTaskId || !taskById.has(task.parentTaskId)) {
|
||||
continue
|
||||
}
|
||||
const children = result.get(task.parentTaskId) ?? []
|
||||
children.push(task)
|
||||
result.set(task.parentTaskId, children)
|
||||
}
|
||||
return result
|
||||
}, [taskById, tasks])
|
||||
const contextTasks = useMemo(
|
||||
() =>
|
||||
tasks.filter(
|
||||
(task) =>
|
||||
(!context?.documentId && !context?.sourceId) ||
|
||||
(Boolean(context?.documentId) && task.documentId === context?.documentId) ||
|
||||
(Boolean(context?.sourceId) && task.sourceId === context?.sourceId)
|
||||
),
|
||||
[context, tasks]
|
||||
)
|
||||
const directMatches = useMemo(
|
||||
() =>
|
||||
contextTasks.filter((task) => {
|
||||
if (filter === 'active') {
|
||||
return task.status === 'queued' || task.status === 'running'
|
||||
}
|
||||
if (filter === 'failed') {
|
||||
return (
|
||||
task.status === 'failed' ||
|
||||
task.status === 'interrupted'
|
||||
)
|
||||
}
|
||||
if (filter === 'history') {
|
||||
return ![
|
||||
'queued',
|
||||
'running',
|
||||
'failed',
|
||||
'interrupted'
|
||||
].includes(task.status)
|
||||
}
|
||||
return true
|
||||
}),
|
||||
[contextTasks, filter]
|
||||
)
|
||||
const visibleIds = useMemo(() => {
|
||||
const result = new Set(directMatches.map((task) => task.id))
|
||||
for (const task of directMatches) {
|
||||
let parentId = task.parentTaskId
|
||||
while (parentId && taskById.has(parentId)) {
|
||||
result.add(parentId)
|
||||
parentId = taskById.get(parentId)?.parentTaskId
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [directMatches, taskById])
|
||||
const topLevelTasks = tasks.filter(
|
||||
(task) =>
|
||||
visibleIds.has(task.id) &&
|
||||
(!task.parentTaskId || !taskById.has(task.parentTaskId))
|
||||
)
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: t('tasks.filters.all') },
|
||||
{ value: 'active', label: t('tasks.filters.active') },
|
||||
{ value: 'failed', label: t('tasks.filters.failed') },
|
||||
{ value: 'history', label: t('tasks.filters.history') }
|
||||
] as const
|
||||
const runTaskAction = async (
|
||||
taskId: string,
|
||||
action: KnowledgeTaskAction,
|
||||
invoke: (taskId: string) => void | Promise<void>
|
||||
): Promise<void> => {
|
||||
setPendingActions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(taskId, action)
|
||||
return next
|
||||
})
|
||||
setActionErrors((current) => {
|
||||
if (!current.has(taskId)) {
|
||||
return current
|
||||
}
|
||||
const next = new Map(current)
|
||||
next.delete(taskId)
|
||||
return next
|
||||
})
|
||||
try {
|
||||
await invoke(taskId)
|
||||
} catch (reason) {
|
||||
setActionErrors((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(taskId, {
|
||||
action,
|
||||
message: toErrorMessage(reason, t)
|
||||
})
|
||||
return next
|
||||
})
|
||||
} finally {
|
||||
setPendingActions((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(taskId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const renderTask = (
|
||||
task: KnowledgeTaskItem,
|
||||
nested = false
|
||||
): React.JSX.Element => {
|
||||
const children = (childrenByParent.get(task.id) ?? []).filter(
|
||||
(child) => visibleIds.has(child.id)
|
||||
)
|
||||
const hasChildren = children.length > 0
|
||||
const isExpanded = expanded.has(task.id)
|
||||
const detailsId = `knowledge-task-${task.id}-details`
|
||||
const actionErrorId = `knowledge-task-${task.id}-action-error`
|
||||
const pendingAction = pendingActions.get(task.id)
|
||||
const actionError = actionErrors.get(task.id)
|
||||
const time =
|
||||
task.completedAt ??
|
||||
task.updatedAt ??
|
||||
task.startedAt ??
|
||||
task.createdAt
|
||||
return (
|
||||
<li
|
||||
className={`knowledge-task${
|
||||
nested ? ' knowledge-task--child' : ''
|
||||
}`}
|
||||
data-status={task.status}
|
||||
key={task.id}
|
||||
>
|
||||
<div className="knowledge-task__heading">
|
||||
<div className="knowledge-task__identity">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
aria-controls={detailsId}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t(
|
||||
isExpanded
|
||||
? 'tasks.actions.collapse'
|
||||
: 'tasks.actions.expand',
|
||||
{ name: task.documentName }
|
||||
)}
|
||||
className="knowledge-task__disclosure"
|
||||
onClick={() =>
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(task.id)) {
|
||||
next.delete(task.id)
|
||||
} else {
|
||||
next.add(task.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown aria-hidden="true" size={16} />
|
||||
) : (
|
||||
<ChevronRight aria-hidden="true" size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="knowledge-task__disclosure-placeholder"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<strong>{task.documentName}</strong>
|
||||
<span>
|
||||
<span>{t(taskKindLabelKeys[task.kind])}</span>
|
||||
{' · '}
|
||||
<span>{t(taskScopeLabelKeys[task.scope])}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`knowledge-task__status knowledge-task__status--${task.status}`}
|
||||
>
|
||||
{t(taskStatusLabelKeys[task.status])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="knowledge-task__stage">
|
||||
<strong>{t('tasks.currentStage')}</strong>
|
||||
<span>{t(taskStageLabelKeys[task.stage])}</span>
|
||||
{task.completedItems !== undefined &&
|
||||
task.totalItems !== undefined && (
|
||||
<span>
|
||||
{t('tasks.itemProgress', {
|
||||
completed: formatNumber(task.completedItems, locale),
|
||||
total: formatNumber(task.totalItems, locale)
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="knowledge-task__progress">
|
||||
<progress
|
||||
aria-label={t('tasks.progressAriaLabel', {
|
||||
name: task.documentName,
|
||||
kind: t(taskKindLabelKeys[task.kind])
|
||||
})}
|
||||
max={100}
|
||||
value={clampProgress(task.progress)}
|
||||
/>
|
||||
<span>
|
||||
{formatPercent(clampProgress(task.progress) / 100, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="knowledge-task__meta">
|
||||
<span>{task.message || t('tasks.waiting')}</span>
|
||||
<time dateTime={time}>
|
||||
{formatDateTime(time, locale, t)}
|
||||
</time>
|
||||
</div>
|
||||
{task.error && (
|
||||
<div className="knowledge-task__error">
|
||||
<strong>{t('tasks.errorTitle')}</strong>
|
||||
<span>{task.error.message}</span>
|
||||
<span>
|
||||
{task.error.remedy ?? t('tasks.defaultRemedy')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(task.canCancel || task.canRetry) && (
|
||||
<div
|
||||
aria-describedby={actionError ? actionErrorId : undefined}
|
||||
className="knowledge-task__actions"
|
||||
>
|
||||
{task.canCancel && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runTaskAction(
|
||||
task.id,
|
||||
'cancel',
|
||||
onCancelTask
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{pendingAction === 'cancel' ? (
|
||||
<LoaderCircle aria-hidden="true" size={14} />
|
||||
) : (
|
||||
<X aria-hidden="true" size={14} />
|
||||
)}
|
||||
{pendingAction === 'cancel'
|
||||
? t('tasks.actions.cancelling')
|
||||
: t('tasks.actions.cancel')}
|
||||
</button>
|
||||
)}
|
||||
{task.canRetry && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runTaskAction(task.id, 'retry', onRetryTask)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{pendingAction === 'retry' ? (
|
||||
<LoaderCircle aria-hidden="true" size={14} />
|
||||
) : (
|
||||
<RotateCcw aria-hidden="true" size={14} />
|
||||
)}
|
||||
{pendingAction === 'retry'
|
||||
? t('tasks.actions.retrying')
|
||||
: t('tasks.actions.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{actionError && (
|
||||
<div
|
||||
className="knowledge-task__action-error"
|
||||
id={actionErrorId}
|
||||
role="alert"
|
||||
>
|
||||
<strong>
|
||||
{t(
|
||||
actionError.action === 'cancel'
|
||||
? 'tasks.actionErrors.cancelTitle'
|
||||
: 'tasks.actionErrors.retryTitle'
|
||||
)}
|
||||
</strong>
|
||||
<span>{actionError.message}</span>
|
||||
<span>{t('tasks.actionErrors.recovery')}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && isExpanded && (
|
||||
<ol className="knowledge-task__children" id={detailsId}>
|
||||
{children.map((child) => renderTask(child, true))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
description={t('tasks.emptyDescription')}
|
||||
icon={<ListChecks size={30} />}
|
||||
level="section"
|
||||
title={t('tasks.emptyTitle')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="knowledge-tasks-title"
|
||||
className="knowledge-tasks"
|
||||
>
|
||||
<div className="knowledge-tasks__summary">
|
||||
<div>
|
||||
<h3 id="knowledge-tasks-title">
|
||||
{t('tasks.title')}
|
||||
</h3>
|
||||
<p className="knowledge-section-description">
|
||||
{t('tasks.totalCount', {
|
||||
count: formatNumber(tasks.length, locale)
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="knowledge-tasks__metrics">
|
||||
<span>
|
||||
{t('tasks.activeCount', {
|
||||
count: formatNumber(activeCount, locale)
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('tasks.failedCount', {
|
||||
count: formatNumber(failedCount, locale)
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('tasks.historyCount', {
|
||||
count: formatNumber(historyCount, locale)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-tasks__toolbar">
|
||||
<SegmentedControl
|
||||
ariaLabel={t('tasks.filters.ariaLabel')}
|
||||
onChange={setFilter}
|
||||
options={filterOptions}
|
||||
value={filter}
|
||||
/>
|
||||
{context && (
|
||||
<div className="knowledge-tasks__context">
|
||||
<span>{t('tasks.context.active')}</span>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onClearContext}
|
||||
type="button"
|
||||
>
|
||||
{t('tasks.context.clear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{topLevelTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
description={t('tasks.noResultsDescription')}
|
||||
icon={<ListChecks size={28} />}
|
||||
level="section"
|
||||
title={t('tasks.noResultsTitle')}
|
||||
/>
|
||||
) : (
|
||||
<ol className="knowledge-task-list">
|
||||
{topLevelTasks.map((task) => renderTask(task))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import type {
|
||||
KnowledgeWorkspaceProps,
|
||||
KnowledgeStorageMode,
|
||||
KnowledgeGraphStrategy,
|
||||
KnowledgeLibrary
|
||||
} from './types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useId, useRef, useEffect } from 'react'
|
||||
import { toErrorMessage, strategyLabelKeys } from './helpers'
|
||||
import { InlineHelp } from '../InlineHelp'
|
||||
import { LoaderCircle, Check, AlertCircle, Trash2 } from 'lucide-react'
|
||||
import { activateModalFocus, trapTabFocus } from '../dialog-focus'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export 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<KnowledgeStorageMode>('reference')
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const advancedId = useId()
|
||||
const [graphEnabled, setGraphEnabled] = useState(false)
|
||||
const [graphStrategy, setGraphStrategy] =
|
||||
useState<KnowledgeGraphStrategy>('rules')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const submit = async (
|
||||
event: React.FormEvent<HTMLFormElement>
|
||||
): Promise<void> => {
|
||||
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 (
|
||||
<form
|
||||
aria-label={t('create.ariaLabel')}
|
||||
className="knowledge-create"
|
||||
onSubmit={(event) => void submit(event)}
|
||||
>
|
||||
<div>
|
||||
<span className="knowledge-create__eyebrow">
|
||||
{t('create.eyebrow')}
|
||||
</span>
|
||||
<h2 className="knowledge-create__title">
|
||||
{t('create.title')}
|
||||
</h2>
|
||||
<p className="knowledge-section-description">
|
||||
{t('create.description')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.name')}
|
||||
<input
|
||||
autoFocus
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
value={name}
|
||||
/>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.description')}
|
||||
<textarea
|
||||
onChange={(event) =>
|
||||
setDescription(event.currentTarget.value)
|
||||
}
|
||||
rows={3}
|
||||
value={description}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-help-label">
|
||||
<details className="knowledge-create__advanced" open={advancedOpen}>
|
||||
<summary aria-controls={advancedOpen ? advancedId : undefined} onClick={(event) => {
|
||||
event.preventDefault()
|
||||
setAdvancedOpen(!advancedOpen)
|
||||
}}>
|
||||
<span>
|
||||
<strong>{t('create.advanced')}</strong>
|
||||
</span>
|
||||
</summary>
|
||||
</details>
|
||||
<InlineHelp label={t('create.advanced')}>{t('create.advancedDescription')}</InlineHelp>
|
||||
</div>
|
||||
{advancedOpen && (
|
||||
<div className="knowledge-create__advanced-content" id={advancedId}>
|
||||
<fieldset className="knowledge-create__storage">
|
||||
<legend>
|
||||
{t('fields.storageMode')}
|
||||
</legend>
|
||||
{(
|
||||
[
|
||||
[
|
||||
'reference',
|
||||
t('storageModes.reference.label'),
|
||||
t('storageModes.reference.description')
|
||||
],
|
||||
[
|
||||
'managed',
|
||||
t('storageModes.managed.label'),
|
||||
t('storageModes.managed.description')
|
||||
]
|
||||
] as const
|
||||
).map(([value, title, detail]) => (
|
||||
<label
|
||||
className="knowledge-create__storage-option"
|
||||
key={value}
|
||||
>
|
||||
<input
|
||||
checked={storageMode === value}
|
||||
name="storage-mode"
|
||||
onChange={() => setStorageMode(value)}
|
||||
type="radio"
|
||||
/>
|
||||
<span>
|
||||
<strong>{title}</strong>
|
||||
<span className="knowledge-muted">{detail}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<label
|
||||
className="knowledge-create__graph-toggle toggle-row"
|
||||
>
|
||||
<input
|
||||
checked={graphEnabled}
|
||||
onChange={(event) => setGraphEnabled(event.currentTarget.checked)}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{t('graph.enable')}
|
||||
</strong>
|
||||
<span className="knowledge-muted">{t('graph.enableDescription')}</span>
|
||||
</span>
|
||||
</label>
|
||||
{graphEnabled && (
|
||||
<label className="knowledge-field">
|
||||
{t('fields.graphGenerationStrategy')}
|
||||
<select
|
||||
onChange={(event) =>
|
||||
setGraphStrategy(
|
||||
event.currentTarget.value as KnowledgeGraphStrategy
|
||||
)
|
||||
}
|
||||
value={graphStrategy}
|
||||
>
|
||||
{Object.entries(strategyLabelKeys).map(([value, key]) => (
|
||||
<option key={value} value={value}>
|
||||
{t(key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="knowledge-create__error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="knowledge-create__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving ? (
|
||||
<LoaderCircle aria-hidden="true" size={16} />
|
||||
) : (
|
||||
<Check aria-hidden="true" size={16} />
|
||||
)}
|
||||
{saving ? t('actions.creating') : t('actions.createLibrary')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditLibraryDialog({
|
||||
library,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
library: KnowledgeLibrary
|
||||
onCancel: () => void
|
||||
onConfirm: (update: {
|
||||
name: string
|
||||
description: string
|
||||
}) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [name, setName] = useState(library.name)
|
||||
const [description, setDescription] = useState(library.description ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(
|
||||
() => activateModalFocus(() => nameRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
const submit = async (
|
||||
event: React.FormEvent<HTMLFormElement>
|
||||
): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const normalizedName = name.trim()
|
||||
if (!normalizedName) {
|
||||
setError(t('validation.libraryNameRequired'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onConfirm({
|
||||
name: normalizedName,
|
||||
description: description.trim()
|
||||
})
|
||||
onCancel()
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label={t('edit.ariaLabel')}
|
||||
aria-modal="true"
|
||||
className="knowledge-library-dialog-backdrop"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !saving) {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
>
|
||||
<form
|
||||
aria-label={t('edit.formAriaLabel')}
|
||||
className="knowledge-library-dialog"
|
||||
onSubmit={(event) => void submit(event)}
|
||||
>
|
||||
<div>
|
||||
<h2>{t('edit.title')}</h2>
|
||||
<p className="knowledge-library-dialog__description">
|
||||
{t('edit.description')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.name')}
|
||||
<input
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
ref={nameRef}
|
||||
value={name}
|
||||
/>
|
||||
</label>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.description')}
|
||||
<textarea
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
rows={4}
|
||||
value={description}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p className="knowledge-inline-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="knowledge-library-dialog__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={saving}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving ? (
|
||||
<LoaderCircle aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<Check aria-hidden="true" size={15} />
|
||||
)}
|
||||
{saving ? t('actions.saving') : t('actions.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export function DeleteLibraryDialog({
|
||||
library,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
library: KnowledgeLibrary
|
||||
onCancel: () => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(
|
||||
() => activateModalFocus(() => cancelRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
const confirm = async (): Promise<void> => {
|
||||
setDeleting(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onConfirm()
|
||||
onCancel()
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label={t('delete.ariaLabel')}
|
||||
aria-modal="true"
|
||||
className="knowledge-library-dialog-backdrop"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !deleting) {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="knowledge-library-dialog knowledge-library-dialog--delete"
|
||||
>
|
||||
<AlertCircle color="var(--danger)" aria-hidden="true" size={26} />
|
||||
<h2>
|
||||
{t('delete.title', { name: library.name })}
|
||||
</h2>
|
||||
<p className="knowledge-library-dialog__description">
|
||||
{library.storageMode === 'managed'
|
||||
? t('delete.managedDescription')
|
||||
: t('delete.referenceDescription')}
|
||||
</p>
|
||||
{error && (
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="knowledge-inline-error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="knowledge-library-dialog__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={deleting}
|
||||
onClick={onCancel}
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={deleting}
|
||||
onClick={() => void confirm()}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
{deleting ? t('actions.deleting') : t('actions.confirmDelete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { KnowledgeSource, KnowledgeStorageMode } from './types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useRef, useId, useEffect } from 'react'
|
||||
import { activateModalFocus, trapTabFocus } from '../dialog-focus'
|
||||
import { toErrorMessage } from './helpers'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertCircle, Trash2 } from 'lucide-react'
|
||||
|
||||
export function RemoveSourceDialog({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
source,
|
||||
storageMode
|
||||
}: {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
source: KnowledgeSource
|
||||
storageMode: KnowledgeStorageMode
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
|
||||
useEffect(
|
||||
() => activateModalFocus(() => cancelRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
setRemoving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onConfirm()
|
||||
onCancel()
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className="knowledge-confirm-dialog-backdrop"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !removing) {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="alertdialog"
|
||||
>
|
||||
<section className="knowledge-confirm-dialog">
|
||||
<AlertCircle aria-hidden="true" size={26} />
|
||||
<h2 id={titleId}>
|
||||
{t('documents.removeSource.title', { name: source.name })}
|
||||
</h2>
|
||||
<p id={descriptionId}>
|
||||
{t(
|
||||
storageMode === 'managed'
|
||||
? 'documents.removeSource.managedDescription'
|
||||
: 'documents.removeSource.referenceDescription',
|
||||
{ count: source.documentCount }
|
||||
)}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="knowledge-inline-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="knowledge-confirm-dialog__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={removing}
|
||||
onClick={onCancel}
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={removing}
|
||||
onClick={() => void remove()}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
{removing
|
||||
? t('documents.removeSource.removing')
|
||||
: t('documents.removeSource.action')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export 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<string, LocaleFormatters>()
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function formatNumber(value: number, locale: string): string {
|
||||
return getLocaleFormatters(locale).integer.format(value)
|
||||
}
|
||||
|
||||
export function formatPercent(value: number, locale: string): string {
|
||||
return getLocaleFormatters(locale).percent.format(value)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { KnowledgeGraphStrategy, KnowledgeTaskItem } from './types'
|
||||
import type { TFunction } from 'i18next'
|
||||
|
||||
export const strategyLabelKeys = {
|
||||
rules: 'strategies.rules',
|
||||
model: 'strategies.model',
|
||||
hybrid: 'strategies.hybrid',
|
||||
ask: 'strategies.ask'
|
||||
} as const satisfies Record<KnowledgeGraphStrategy, string>
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export 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<KnowledgeTaskItem['stage'], string>
|
||||
|
||||
export function clampProgress(progress: number | undefined): number {
|
||||
if (!Number.isFinite(progress)) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(100, Math.max(0, progress ?? 0))
|
||||
}
|
||||
|
||||
export function toErrorMessage(
|
||||
reason: unknown,
|
||||
t: TFunction<'knowledge'>
|
||||
): string {
|
||||
return reason instanceof Error && reason.message
|
||||
? reason.message
|
||||
: t('errors.operationFailed')
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type {
|
||||
KnowledgeLibrary as SharedKnowledgeLibrary,
|
||||
KnowledgeSourceItem as SharedKnowledgeSource,
|
||||
KnowledgeDocumentItem as SharedKnowledgeDocumentItem,
|
||||
KnowledgeGraphNode as SharedKnowledgeGraphNode,
|
||||
KnowledgeGraphRelation as SharedKnowledgeGraphRelation,
|
||||
KnowledgeEvidence as SharedKnowledgeEvidence,
|
||||
KnowledgeTaskItem as SharedKnowledgeTaskItem,
|
||||
KnowledgeSnapshot
|
||||
} from '../../../shared/contracts'
|
||||
import type { ExternalKnowledgeInstanceSummary } from '../../../shared/external-knowledge-contracts'
|
||||
import type { AppNotificationInput } from '../notifications'
|
||||
import type {
|
||||
KnowledgeRetrievalSettings,
|
||||
KnowledgeRetrievalResponse,
|
||||
KnowledgeChunkingSettings,
|
||||
KnowledgeChunkPage,
|
||||
KnowledgeChunkUpdateInput
|
||||
} from '../../../shared/knowledge-contracts'
|
||||
import type { KnowledgeOntologySettings } from '../../../shared/knowledge-ontology'
|
||||
import type { KnowledgeEmbeddingIndexSnapshot } from '../../../shared/embedding-contracts'
|
||||
|
||||
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<void>
|
||||
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<void>
|
||||
onSelectLibrary: (libraryId: string) => void
|
||||
onCreateLibrary: (
|
||||
input: CreateKnowledgeLibraryInput
|
||||
) => void | Promise<void>
|
||||
onDeleteLibrary: (libraryId: string) => void | Promise<void>
|
||||
onUpdateLibrary: (
|
||||
libraryId: string,
|
||||
update: {
|
||||
name?: string
|
||||
description?: string
|
||||
graphEnabled?: boolean
|
||||
graphStrategy?: KnowledgeGraphStrategy
|
||||
}
|
||||
) => void | Promise<void>
|
||||
onReextractGraph: (libraryId: string) => void | Promise<void>
|
||||
onImportFiles: (
|
||||
libraryId: string,
|
||||
files: File[],
|
||||
graphStrategy?: Exclude<KnowledgeGraphStrategy, 'ask'>
|
||||
) => void | Promise<void>
|
||||
onImportDirectory: (
|
||||
libraryId: string,
|
||||
files: File[],
|
||||
graphStrategy?: Exclude<KnowledgeGraphStrategy, 'ask'>
|
||||
) => void | Promise<void>
|
||||
onImportUrl: (
|
||||
libraryId: string,
|
||||
url: string,
|
||||
graphStrategy?: Exclude<KnowledgeGraphStrategy, 'ask'>
|
||||
) => void | Promise<void>
|
||||
onSyncSource: (sourceId: string) => void | Promise<void>
|
||||
onPauseSource: (sourceId: string) => void | Promise<void>
|
||||
onRetrySource: (sourceId: string) => void | Promise<void>
|
||||
onRemoveSource: (sourceId: string) => void | Promise<void>
|
||||
onRetrieve: (
|
||||
libraryId: string,
|
||||
query: string,
|
||||
settings: KnowledgeRetrievalSettings
|
||||
) => Promise<KnowledgeRetrievalResponse>
|
||||
onUpdateKnowledgeSettings: (
|
||||
libraryId: string,
|
||||
settings: {
|
||||
retrieval?: KnowledgeRetrievalSettings
|
||||
chunking?: KnowledgeChunkingSettings
|
||||
ontology?: KnowledgeOntologySettings
|
||||
}
|
||||
) => void | Promise<void>
|
||||
onListChunks: (input: {
|
||||
libraryId: string
|
||||
documentId: string
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
}) => Promise<KnowledgeChunkPage>
|
||||
onUpdateChunk: (
|
||||
input: KnowledgeChunkUpdateInput
|
||||
) => void | Promise<void>
|
||||
onDeleteChunk: (input: {
|
||||
knowledgeBaseId: string
|
||||
documentId: string
|
||||
chunkId: string
|
||||
}) => void | Promise<void>
|
||||
onRebuildDocument: (
|
||||
libraryId: string,
|
||||
documentId: string
|
||||
) => void | Promise<void>
|
||||
onOpenDocumentSource: (
|
||||
libraryId: string,
|
||||
documentId: string
|
||||
) => void | Promise<void>
|
||||
onRebuildLibrary: (libraryId: string) => void | Promise<void>
|
||||
onCancelRebuild: (libraryId: string) => void | Promise<void>
|
||||
onGetEmbeddingIndex: (
|
||||
libraryId: string
|
||||
) =>
|
||||
| KnowledgeEmbeddingIndexSnapshot
|
||||
| Promise<KnowledgeEmbeddingIndexSnapshot>
|
||||
onRebuildEmbeddingIndex: (
|
||||
libraryId: string
|
||||
) => Promise<KnowledgeEmbeddingIndexSnapshot>
|
||||
onCancelTask: (taskId: string) => void | Promise<void>
|
||||
onRetryTask: (taskId: string) => void | Promise<void>
|
||||
onOpenReferenceSource: (input: {
|
||||
knowledgeBaseId: string
|
||||
documentId: string
|
||||
chunkId: string
|
||||
}) => void | Promise<void>
|
||||
onUseInChat: (libraryId: string) => void
|
||||
onOpenModelSettings: () => void
|
||||
onMoveNode: (
|
||||
nodeId: string,
|
||||
position: { x: number; y: number }
|
||||
) => void
|
||||
onCreateEntity: (
|
||||
input: KnowledgeEntityUpdate
|
||||
) => void | Promise<void>
|
||||
onUpdateEntity: (
|
||||
nodeId: string,
|
||||
update: KnowledgeEntityUpdate
|
||||
) => void | Promise<void>
|
||||
onDeleteEntity: (nodeId: string) => void | Promise<void>
|
||||
onMergeEntities: (
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string
|
||||
) => void | Promise<void>
|
||||
onCreateRelation: (
|
||||
relation: KnowledgeRelationInput
|
||||
) => void | Promise<void>
|
||||
onUpdateRelation: (
|
||||
relationId: string,
|
||||
relation: KnowledgeRelationInput
|
||||
) => void | Promise<void>
|
||||
onDeleteRelation: (relationId: string) => void | Promise<void>
|
||||
onOpenEvidence?: (evidence: KnowledgeEvidence) => void
|
||||
}
|
||||
+61
-19
@@ -14022,13 +14022,15 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-documents__drop-zone {
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-control);
|
||||
margin-top: var(--space-3);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.knowledge-documents__drop-zone--dragging {
|
||||
@@ -14037,8 +14039,8 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.knowledge-documents__drop-zone > div {
|
||||
margin-top: 5px;
|
||||
.knowledge-documents__drop-zone > svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.knowledge-documents__empty {
|
||||
@@ -14053,24 +14055,46 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.knowledge-source-list {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
margin: var(--space-3) 0 0;
|
||||
gap: 9px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.knowledge-source-row {
|
||||
display: grid;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.knowledge-source-group {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.knowledge-document-row--grouped > td:first-child {
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
.knowledge-documents__toolbar {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.knowledge-document-more {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.knowledge-document-more > summary {
|
||||
width: fit-content;
|
||||
min-height: var(--control-height);
|
||||
align-content: center;
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.knowledge-document-more > summary:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.knowledge-document-more > summary:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.knowledge-source-row__main,
|
||||
.knowledge-source-row__identity {
|
||||
min-width: 0;
|
||||
@@ -14079,6 +14103,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.knowledge-source-row__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -14089,6 +14114,8 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-source-row__status {
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-subtle);
|
||||
@@ -14165,7 +14192,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-documents__table-scroll th {
|
||||
padding: 9px 10px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.knowledge-documents__table-scroll tbody tr {
|
||||
@@ -14173,7 +14200,22 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-documents__table-scroll td {
|
||||
padding: 10px;
|
||||
padding: var(--space-3);
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.knowledge-documents__table-scroll .knowledge-document-metric {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-documents__table-scroll td:last-child:not([colspan]) {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.knowledge-documents__table-scroll button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-document-path {
|
||||
|
||||
Reference in New Issue
Block a user