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