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:
mesalogo
2026-09-27 10:05:50 +08:00
parent 4edf06b685
commit 5177b47aeb
19 changed files with 4613 additions and 4308 deletions
+19
View File
@@ -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。
+19
View File
@@ -64,6 +64,25 @@ Conversation 的检索范围,返回对话并聚焦输入框。
## 6. 来源操作
“文档与来源”使用一张按来源组织的表格。单文件、单 URL 来源只有一篇关联文档时,
来源与文档合成一行,以文档名为主标题,同步状态、同步时间和索引状态就近显示。
不再单独展示同名来源行,也不重复任务入口。目录及其他一对多来源保留来源组标题,
文档紧随其后;零文档和失败来源仍保留同步、暂停、重试及移除入口。
没有 `sourceId` 或找不到对应来源的文档独立显示。
搜索同时匹配来源名称、来源位置、文档名称和路径。来源命中时显示该组全部文档;
仅文档命中时保留组标题和命中文档。一对多关系按完整数据判断,搜索不能将其临时合成
单文档行。移除确认使用来源的完整文档数量,不使用搜索结果数。
导入文件、目录、URL 及拖放入口保持可用,拖放区使用紧凑的图标与文字提示。
单文档行的同步、移除和重新解析收进原生“更多操作”折叠区,解析预览、打开来源、
分块和任务保留直接入口。失败文档的重新处理入口仍直接显示。合并行的任务入口包含
该来源及该文档的相关任务,保留没有来源 ID 的文档任务。大小与分块数不拆行,操作组
按可用宽度换行,窄窗口在表格内部横向滚动。
解析预览复用现有结果组件;返回列表时恢复搜索、原触发按钮焦点及页面滚动位置。
重新解析继续调用文档重建,使用当前设置并保留上次结果,失败时显示可恢复错误。
文档行提供“打开来源”,Main 根据知识库和文档归属重新读取保存的文件路径或 URL。
移除来源使用确认对话框,说明文档数、索引、图谱证据和托管副本影响。初始焦点位于
取消,提交期间禁止重复操作,失败时保留对话框和错误。
+163 -18
View File
@@ -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
View File
@@ -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 {