@@ -5524,7 +5650,7 @@ function App(): React.JSX.Element {
{message.sourceReferences.map(
(reference, referenceIndex) => (
[{referenceIndex + 1}]{' '}
@@ -5541,6 +5667,8 @@ function App(): React.JSX.Element {
.map((channel) =>
channel === 'fts'
? t('chat.citations.fullText')
+ : channel === 'cjk'
+ ? t('chat.citations.cjk')
: channel === 'vector'
? t('chat.citations.vector')
: t('chat.citations.graph')
@@ -5548,6 +5676,55 @@ function App(): React.JSX.Element {
.join(' + ')}
)}
+ {reference.score !== undefined && (
+
+ {t('chat.citations.score', {
+ score: reference.score.toFixed(4)
+ })}
+
+ )}
+
+
+
+
)
)}
@@ -5998,6 +6175,47 @@ function App(): React.JSX.Element {
))}
+
+
+ {t('composer.knowledge.modeLabel')}
+
+
+ setConversations((current) =>
+ current.map((conversation) =>
+ conversation.id === activeId
+ ? {
+ ...conversation,
+ knowledgeRetrievalMode: mode,
+ updatedAt: Date.now()
+ }
+ : conversation
+ )
+ )
+ }
+ options={[
+ {
+ value: 'auto',
+ label: t('composer.knowledge.auto')
+ },
+ {
+ value: 'always',
+ label: t('composer.knowledge.always')
+ }
+ ]}
+ value={
+ activeConversation?.knowledgeRetrievalMode ??
+ 'auto'
+ }
+ />
+
+ {activeConversation?.knowledgeRetrievalMode ===
+ 'always'
+ ? t('composer.knowledge.alwaysDescription')
+ : t('composer.knowledge.autoDescription')}
+
+
)}
@@ -6460,11 +6678,123 @@ function App(): React.JSX.Element {
window.goodbuddy.knowledge.removeSource(sourceId)
)
}
+ onRetrieve={(libraryId, query, settings) =>
+ window.goodbuddy.knowledge.retrieve({
+ knowledgeBaseId: libraryId,
+ query,
+ settings
+ })
+ }
+ onUpdateKnowledgeSettings={async (
+ libraryId,
+ settings
+ ) => {
+ await runKnowledgeSourceAction(() =>
+ window.goodbuddy.knowledge.updateSettings({
+ knowledgeBaseId: libraryId,
+ ...settings
+ })
+ )
+ notify({
+ tone: 'success',
+ message: t('notices.knowledgeSettingsUpdated'),
+ dedupeKey: `knowledge-retrieval-settings:${libraryId}`
+ })
+ }}
+ onListChunks={({
+ libraryId,
+ documentId,
+ page,
+ pageSize,
+ search
+ }) =>
+ window.goodbuddy.knowledge.listChunks({
+ knowledgeBaseId: libraryId,
+ documentId,
+ page,
+ pageSize,
+ search
+ })
+ }
+ onUpdateChunk={(input) =>
+ window.goodbuddy.knowledge.updateChunk(input)
+ }
+ onDeleteChunk={(input) =>
+ window.goodbuddy.knowledge.deleteChunk(input)
+ }
+ onRebuildDocument={(libraryId, documentId) =>
+ runKnowledgeSourceAction(async () => {
+ await window.goodbuddy.knowledge.rebuildDocument({
+ knowledgeBaseId: libraryId,
+ documentId
+ })
+ })
+ }
+ onRebuildLibrary={(libraryId) =>
+ runKnowledgeSourceAction(async () => {
+ const result =
+ await window.goodbuddy.knowledge.rebuildLibrary({
+ knowledgeBaseId: libraryId
+ })
+ if (result.failed > 0) {
+ throw new Error(
+ t('notices.knowledgeRebuildPartial', {
+ rebuilt: result.rebuilt,
+ failed: result.failed
+ })
+ )
+ }
+ notify({
+ tone: 'success',
+ message: t('notices.knowledgeRebuildCompleted', {
+ count: result.rebuilt
+ }),
+ dedupeKey: `knowledge-rebuild:${libraryId}`
+ })
+ })
+ }
+ onCancelRebuild={async (libraryId) => {
+ const cancelled =
+ await window.goodbuddy.knowledge.cancelRebuild(
+ libraryId
+ )
+ if (!cancelled) {
+ throw new Error(
+ t('notices.knowledgeRebuildNotRunning')
+ )
+ }
+ }}
+ onGetEmbeddingIndex={(libraryId) =>
+ window.goodbuddy.knowledge.getEmbeddingIndex(libraryId)
+ }
+ onRebuildEmbeddingIndex={(libraryId) =>
+ window.goodbuddy.knowledge.rebuildEmbeddingIndex(
+ libraryId
+ )
+ }
+ onCancelTask={async (taskId) => {
+ const cancelled =
+ await window.goodbuddy.knowledge.cancelTask(taskId)
+ if (!cancelled) {
+ throw new Error(
+ t('notices.knowledgeTaskNotRunning')
+ )
+ }
+ await refreshSelectedKnowledge()
+ }}
+ onOpenReferenceSource={(input) =>
+ window.goodbuddy.knowledge.openReferenceSource(input)
+ }
onRetrySource={(sourceId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.retrySource(sourceId)
)
}
+ onRetryTask={(taskId) =>
+ runKnowledgeSourceAction(() =>
+ window.goodbuddy.knowledge.retryTask(taskId)
+ )
+ }
onRetryLoad={retryKnowledgeLoad}
onSelectLibrary={(libraryId) => {
void refreshKnowledge(libraryId).catch(() => {
@@ -6579,6 +6909,26 @@ function App(): React.JSX.Element {
snapshot={releaseNotes}
/>
)}
+ {citationDialog && (
+ setCitationDialog(undefined)}
+ onOpenSource={async () => {
+ const { reference } = citationDialog
+ if (!reference.chunkId) {
+ throw new Error(t('chat.citations.contextUnavailable'))
+ }
+ await window.goodbuddy.knowledge.openReferenceSource({
+ knowledgeBaseId: reference.libraryId,
+ documentId: reference.documentId,
+ chunkId: reference.chunkId
+ })
+ }}
+ reference={citationDialog.reference}
+ />
+ )}
{imageViewerItem && (
{
cleanup()
})
describe('EmbeddingSettingsSection', () => {
- it('renders embedding settings in English without translating model data', async () => {
+ it('keeps model settings limited to connection details and diagnostics', () => {
+ const onTest = vi.fn()
+ render(
+
+ )
+
+ expect(
+ screen.getByRole('heading', { name: '向量模型连接' })
+ ).toBeInTheDocument()
+ expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
+ expect(screen.getByText('已配置凭据')).toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: /重建向量索引/u })
+ ).not.toBeInTheDocument()
+ expect(screen.queryByText(/知识向量索引/u)).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: '测试向量模型' }))
+ expect(onTest).toHaveBeenCalledOnce()
+ })
+
+ it('renders connection copy in English without translating model data', async () => {
await changeUiLocale('en-US')
render(
)
expect(
screen.getByRole('heading', {
- name: 'Embeddings and knowledge retrieval'
+ name: 'Embedding model connection'
})
).toBeInTheDocument()
- expect(
- screen.getByRole('heading', { name: 'Current embedding model' })
- ).toBeInTheDocument()
expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
- expect(screen.getByText('Provider: openai-compatible'))
- .toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Test embedding model' })
).toBeInTheDocument()
- expect(screen.getByText('No rebuild history yet')).toBeInTheDocument()
- })
-
- it('uses supplied callbacks without depending on a preload API', () => {
- const onTest = vi.fn()
- const onRebuild = vi.fn()
- render(
-
- )
-
- expect(
- screen.getByRole('heading', { name: '向量与知识检索' })
- ).toBeInTheDocument()
- expect(
- screen.getByRole('heading', { name: '当前向量模型' })
- ).toBeInTheDocument()
- expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
- expect(screen.getByText('已配置凭据')).toBeInTheDocument()
- expect(screen.getByText('还没有重建记录')).toBeInTheDocument()
- expect(
- screen.getByText(
- '点击“重建向量索引”,为知识文档生成可用于检索的向量。'
- )
- ).toBeInTheDocument()
- expect(screen.queryByText(/快照/)).not.toBeInTheDocument()
- expect(screen.queryByText(/当前检索索引/)).not.toBeInTheDocument()
-
- fireEvent.click(screen.getByRole('button', { name: '测试向量模型' }))
- fireEvent.click(screen.getByRole('button', { name: '重建向量索引' }))
- expect(onTest).toHaveBeenCalledOnce()
- expect(onRebuild).toHaveBeenCalledOnce()
})
it('shows dimensions and latency from a real diagnostic result', () => {
@@ -103,8 +78,6 @@ describe('EmbeddingSettingsSection', () => {
latencyMs: 126,
dimensions: 1_536
}}
- indexStatus={idleIndex}
- onRebuild={vi.fn()}
onTest={vi.fn()}
/>
)
@@ -132,8 +105,6 @@ describe('EmbeddingSettingsSection', () => {
remedy: '请确认模型名称正确。'
}
}}
- indexStatus={idleIndex}
- onRebuild={vi.fn()}
onTest={vi.fn()}
/>
)
@@ -145,142 +116,4 @@ describe('EmbeddingSettingsSection', () => {
'处理建议:请确认模型名称正确。'
)
})
-
- it('shows document progress and atomic availability while rebuilding', () => {
- const onCancel = vi.fn()
- render(
-
- )
-
- expect(screen.getByRole('progressbar')).toHaveAttribute('value', '25')
- expect(screen.getByText('已完成 10 / 40 篇文档')).toBeInTheDocument()
- expect(
- screen.getByText(/每篇文档会一次性更新,处理完成后立即可用于检索。/)
- ).toBeInTheDocument()
- expect(
- screen.getByText(/其余文档的原有或缺失状态不变。/)
- ).toBeInTheDocument()
- expect(
- screen.getByRole('button', { name: '重建进行中…' })
- ).toBeDisabled()
-
- fireEvent.click(
- screen.getByRole('button', { name: '取消向量索引重建' })
- )
- expect(onCancel).toHaveBeenCalledWith('job-new')
- })
-
- it('shows a failed rebuild remedy and retries from the rebuild button', () => {
- const onRebuild = vi.fn()
- render(
-
- )
-
- expect(screen.getByText('最近一次重建失败')).toBeInTheDocument()
- expect(screen.getByRole('alert')).toHaveTextContent(
- '向量服务当前请求过多。'
- )
- expect(screen.getByRole('alert')).toHaveTextContent(
- '已完成 2 / 4 篇文档。发生错误的文档已标记为错误,已完成文档仍可用于检索。'
- )
- expect(screen.getByRole('alert')).toHaveTextContent(
- '请检查向量模型配置和网络连接。修复后点击“重建向量索引”重试。'
- )
-
- fireEvent.click(screen.getByRole('button', { name: '重建向量索引' }))
- expect(onRebuild).toHaveBeenCalledOnce()
- })
-
- it('reports successful and cancelled rebuilds distinctly', () => {
- const { rerender } = render(
-
- )
- expect(screen.getByText('最近一次重建成功')).toBeInTheDocument()
- expect(screen.getByText('已完成 4 / 4 篇文档', { exact: false }))
- .toBeInTheDocument()
-
- rerender(
-
- )
- expect(screen.getByText('最近一次重建已取消')).toBeInTheDocument()
- expect(
- screen.getByText('已完成 2 / 4 篇文档。')
- ).toBeInTheDocument()
- expect(
- screen.getByText(/已完成文档保留新向量;其余文档保留原有向量/)
- ).toBeInTheDocument()
- expect(screen.getByText(/原本没有向量的仍保持缺失。/))
- .toBeInTheDocument()
- expect(screen.queryByText(/索引未更改/)).not.toBeInTheDocument()
- })
})
diff --git a/src/renderer/src/EmbeddingSettingsSection.tsx b/src/renderer/src/EmbeddingSettingsSection.tsx
index 4ff146a..12d66ee 100644
--- a/src/renderer/src/EmbeddingSettingsSection.tsx
+++ b/src/renderer/src/EmbeddingSettingsSection.tsx
@@ -1,35 +1,20 @@
import {
Activity,
- Database,
- FlaskConical,
- RefreshCw,
- XCircle
+ FlaskConical
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type {
EmbeddingConfigurationSummary,
- EmbeddingDiagnosticResult,
- EmbeddingIndexJob,
- EmbeddingIndexStatus
+ EmbeddingDiagnosticResult
} from '../../shared/embedding-contracts'
-import { isEmbeddingIndexJobActive } from '../../shared/embedding-contracts'
+import { formatMediumDateTime } from './locale-formatters'
export interface EmbeddingSettingsSectionProps {
configuration: EmbeddingConfigurationSummary
diagnostic?: EmbeddingDiagnosticResult | null
diagnosticRunning?: boolean
- indexStatus: EmbeddingIndexStatus
disabled?: boolean
onTest: () => void
- onRebuild: () => void
- onCancel?: (jobId: string) => void
-}
-
-function formatCheckedAt(timestamp: number, locale: string): string {
- return new Intl.DateTimeFormat(locale, {
- dateStyle: 'medium',
- timeStyle: 'short'
- }).format(timestamp)
}
function DiagnosticResult({
@@ -51,7 +36,7 @@ function DiagnosticResult({
{t('embedding.diagnostic.checkedAt', {
- date: formatCheckedAt(result.checkedAt, locale)
+ date: formatMediumDateTime(result.checkedAt, locale)
})}
@@ -76,123 +61,14 @@ function DiagnosticResult({
)
}
-function IndexJobStatus({
- job,
- disabled,
- onCancel
-}: {
- job: EmbeddingIndexJob
- disabled: boolean
- onCancel?: (jobId: string) => void
-}): React.JSX.Element {
- const { i18n, t } = useTranslation('settingsSections')
- const locale = i18n.resolvedLanguage ?? i18n.language
- const active = isEmbeddingIndexJobActive(job)
- return (
-
-
-
- {t(`embedding.index.statuses.${job.status}`)}
-
- {job.provider} · {job.model}
-
-
- {active && onCancel && (
-
- )}
-
- {active && (
- <>
-
- )
-}
-
export function EmbeddingSettingsSection({
configuration,
diagnostic,
diagnosticRunning = false,
- indexStatus,
disabled = false,
- onTest,
- onRebuild,
- onCancel
+ onTest
}: EmbeddingSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
- const active = isEmbeddingIndexJobActive(indexStatus.job)
return (
-
-
-
-
-
- {t('embedding.index.heading')}
-
-
-
-
-
- {indexStatus.job ? (
-
- ) : (
-
-
{t('embedding.index.emptyTitle')}
-
{t('embedding.index.emptyDescription')}
-
- )}
-
)
}
diff --git a/src/renderer/src/KnowledgeChunkManager.test.tsx b/src/renderer/src/KnowledgeChunkManager.test.tsx
new file mode 100644
index 0000000..0aba788
--- /dev/null
+++ b/src/renderer/src/KnowledgeChunkManager.test.tsx
@@ -0,0 +1,241 @@
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor
+} from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useState } from 'react'
+import {
+ KnowledgeChunkManager,
+ type KnowledgeChunkManagerProps,
+ type KnowledgeChunkPage
+} from './KnowledgeChunkManager'
+import { changeUiLocale } from './i18n'
+
+const chunkPage: KnowledgeChunkPage = {
+ page: 1,
+ pageSize: 20,
+ totalItems: 21,
+ items: [
+ {
+ id: 'chunk-1',
+ ordinal: 1,
+ role: 'child',
+ parentChunkId: 'parent-1',
+ heading: '安装模型',
+ locator: '第 3 节',
+ characterCount: 18,
+ enabled: true,
+ content: '可以导入已经校验的模型 ZIP。',
+ manuallyEdited: true
+ },
+ {
+ id: 'chunk-2',
+ ordinal: 2,
+ role: 'standalone',
+ locator: '第 4 节',
+ characterCount: 12,
+ enabled: false,
+ content: '服务不可用时使用本地回退。',
+ manuallyEdited: false
+ }
+ ]
+}
+
+function createProps(
+ overrides: Partial = {}
+): KnowledgeChunkManagerProps {
+ return {
+ documentId: 'document-1',
+ documentName: '离线部署.md',
+ page: chunkPage,
+ onList: vi.fn(),
+ onUpdateChunk: vi.fn(),
+ onDeleteChunk: vi.fn(),
+ onRebuildDocument: vi.fn(),
+ onClose: vi.fn(),
+ ...overrides
+ }
+}
+
+afterEach(async () => {
+ cleanup()
+ await changeUiLocale('zh-CN')
+})
+
+describe('KnowledgeChunkManager', () => {
+ it('provides dialog semantics, focuses search, traps focus, and returns focus on Escape', async () => {
+ function Harness(): React.JSX.Element {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
+
+
+ {open && (
+ setOpen(false) })}
+ />
+ )}
+ >
+ )
+ }
+
+ render()
+ const trigger = screen.getByRole('button', { name: '查看分块' })
+ trigger.focus()
+ fireEvent.click(trigger)
+
+ const dialog = screen.getByRole('dialog', { name: '文档分块' })
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
+ expect(screen.getByLabelText('搜索文档内分块')).toHaveFocus()
+ expect(screen.getAllByRole('switch')).toHaveLength(2)
+ expect(
+ screen.getByText('人工修改可能被替换。')
+ ).toBeInTheDocument()
+
+ const rebuild = screen.getByRole('button', { name: '重建文档' })
+ rebuild.focus()
+ fireEvent.keyDown(rebuild, { key: 'Tab' })
+ expect(
+ screen.getByRole('button', { name: '关闭文档分块' })
+ ).toHaveFocus()
+
+ fireEvent.keyDown(dialog, { key: 'Escape' })
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ )
+ expect(trigger).toHaveFocus()
+ })
+
+ it('searches and paginates with a bounded list request', () => {
+ const onList = vi.fn()
+ render()
+
+ fireEvent.change(screen.getByLabelText('搜索文档内分块'), {
+ target: { value: '模型' }
+ })
+ fireEvent.click(screen.getByRole('button', { name: '搜索' }))
+ expect(onList).toHaveBeenCalledWith({
+ documentId: 'document-1',
+ page: 1,
+ pageSize: 20,
+ query: '模型'
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: '下一页分块' }))
+ expect(onList).toHaveBeenLastCalledWith({
+ documentId: 'document-1',
+ page: 2,
+ pageSize: 20,
+ query: '模型'
+ })
+ })
+
+ it('switches enablement, edits content, validates, and preserves a failed draft', () => {
+ const onUpdateChunk = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ fireEvent.click(screen.getByRole('switch', { name: '启用分块 1' }))
+ expect(onUpdateChunk).toHaveBeenCalledWith('chunk-1', {
+ enabled: false
+ })
+
+ const editor = screen.getByLabelText(/^分块内容/)
+ fireEvent.change(editor, { target: { value: '保留的人工修正文稿' } })
+ fireEvent.click(screen.getByRole('button', { name: '保存分块' }))
+ expect(onUpdateChunk).toHaveBeenCalledWith('chunk-1', {
+ content: '保留的人工修正文稿'
+ })
+
+ rerender(
+
+ )
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ '向量重建失败,请重试。'
+ )
+ expect(
+ screen.getByRole('switch', { name: '启用分块 1' })
+ ).toBeChecked()
+ expect(screen.getByLabelText(/^分块内容/)).toHaveValue(
+ '保留的人工修正文稿'
+ )
+
+ fireEvent.change(screen.getByLabelText(/^分块内容/), {
+ target: { value: '' }
+ })
+ expect(screen.getByText('分块内容不能为空。')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '保存分块' })).toBeDisabled()
+ })
+
+ it('confirms a specific deletion, restores trigger focus on cancel, and invokes rebuild', () => {
+ const onDeleteChunk = vi.fn()
+ const onRebuildDocument = vi.fn()
+ render(
+
+ )
+
+ const deleteTrigger = screen.getByRole('button', {
+ name: '删除分块 1'
+ })
+ fireEvent.click(deleteTrigger)
+ const confirmation = screen.getByRole('alertdialog', {
+ name: '确认删除分块 1'
+ })
+ expect(confirmation).toHaveAccessibleDescription(
+ '删除分块 1 会移除其全文、中文、向量和图谱证据。来源同步或重建可能重新创建此分块;原始文件不会被删除。'
+ )
+ expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
+
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
+ expect(
+ screen.getByRole('button', { name: '删除分块 1' })
+ ).toHaveFocus()
+
+ fireEvent.click(screen.getByRole('button', { name: '删除分块 1' }))
+ fireEvent.click(
+ screen.getByRole('button', { name: '确认删除分块 1' })
+ )
+ expect(onDeleteChunk).toHaveBeenCalledWith('chunk-1')
+
+ fireEvent.click(screen.getByRole('button', { name: '重建文档' }))
+ expect(onRebuildDocument).toHaveBeenCalledWith('document-1')
+ })
+
+ it('renders loading and zero states without a selected editor', () => {
+ const { rerender } = render(
+
+ )
+ expect(screen.getByRole('status')).toHaveTextContent('正在加载分块')
+
+ rerender(
+
+ )
+ expect(screen.getByRole('status')).toHaveTextContent(
+ '没有符合条件的分块'
+ )
+ expect(screen.getByText('选择一个分块')).toBeInTheDocument()
+ })
+})
diff --git a/src/renderer/src/KnowledgeChunkManager.tsx b/src/renderer/src/KnowledgeChunkManager.tsx
new file mode 100644
index 0000000..6a03888
--- /dev/null
+++ b/src/renderer/src/KnowledgeChunkManager.tsx
@@ -0,0 +1,498 @@
+import {
+ ChevronLeft,
+ ChevronRight,
+ FilePenLine,
+ RefreshCw,
+ Search,
+ Trash2,
+ X
+} from 'lucide-react'
+import {
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+ type FormEvent
+} from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import type {
+ KnowledgeChunkPage as SharedKnowledgeChunkPage,
+ KnowledgeChunkRole as SharedKnowledgeChunkRole,
+ KnowledgeChunkUpdateInput,
+ KnowledgeManagedChunk as SharedKnowledgeManagedChunk
+} from '../../shared/knowledge-contracts'
+import { activateModalFocus, trapTabFocus } from './dialog-focus'
+import { DestructiveConfirmActions } from './WorkspacePrimitives'
+
+export type KnowledgeChunkRole = SharedKnowledgeChunkRole
+export type KnowledgeManagedChunk = SharedKnowledgeManagedChunk
+export type KnowledgeChunkPage = SharedKnowledgeChunkPage
+export type KnowledgeChunkUpdate = Pick<
+ KnowledgeChunkUpdateInput,
+ 'content' | 'enabled'
+>
+
+export type KnowledgeChunkManagerProps = {
+ documentId: string
+ documentName: string
+ page: KnowledgeChunkPage
+ query?: string
+ selectedChunkId?: string
+ loading?: boolean
+ error?: string
+ savingChunkId?: string
+ deletingChunkId?: string
+ rebuilding?: boolean
+ maxChunkCharacters?: number
+ onList: (request: {
+ documentId: string
+ page: number
+ pageSize: number
+ query: string
+ }) => void | Promise
+ onSelectChunk?: (chunkId: string) => void
+ onUpdateChunk: (
+ chunkId: string,
+ update: KnowledgeChunkUpdate
+ ) => void | Promise
+ onDeleteChunk: (chunkId: string) => void | Promise
+ onRebuildDocument: (documentId: string) => void | Promise
+ onClose: () => void
+}
+
+export function KnowledgeChunkManager({
+ deletingChunkId,
+ documentId,
+ documentName,
+ error,
+ loading = false,
+ maxChunkCharacters = 48_000,
+ onClose,
+ onDeleteChunk,
+ onList,
+ onRebuildDocument,
+ onSelectChunk,
+ onUpdateChunk,
+ page,
+ query = '',
+ rebuilding = false,
+ savingChunkId,
+ selectedChunkId
+}: KnowledgeChunkManagerProps): React.JSX.Element {
+ const { t } = useTranslation('knowledge')
+ const [searchDraft, setSearchDraft] = useState(query)
+ const [internalSelectedId, setInternalSelectedId] = useState(
+ selectedChunkId ?? page.items[0]?.id
+ )
+ const [contentDrafts, setContentDrafts] = useState>({})
+ const [confirmingDeleteId, setConfirmingDeleteId] = useState()
+ const dialogRef = useRef(null)
+ const searchRef = useRef(null)
+ const titleId = useId()
+ const descriptionId = useId()
+ const contentErrorId = useId()
+
+ const requestedSelectedId = selectedChunkId ?? internalSelectedId
+ const selectedChunk =
+ page.items.find((chunk) => chunk.id === requestedSelectedId) ??
+ page.items[0]
+ const effectiveSelectedId = selectedChunk?.id
+ const draftContent = selectedChunk
+ ? (contentDrafts[selectedChunk.id] ?? selectedChunk.content)
+ : ''
+ const isSavingSelected = savingChunkId === selectedChunk?.id
+ const totalPages = Math.max(1, Math.ceil(page.totalItems / page.pageSize))
+
+ useEffect(() => {
+ return activateModalFocus(() => searchRef.current)
+ }, [])
+
+ const contentError = useMemo(() => {
+ if (!selectedChunk) {
+ return undefined
+ }
+ if (draftContent.trim().length === 0) {
+ return t('chunks.validation.contentRequired')
+ }
+ if (draftContent.length > maxChunkCharacters) {
+ return t('chunks.validation.contentTooLong', {
+ count: maxChunkCharacters
+ })
+ }
+ return undefined
+ }, [draftContent, maxChunkCharacters, selectedChunk, t])
+
+ const selectChunk = (chunkId: string): void => {
+ setInternalSelectedId(chunkId)
+ onSelectChunk?.(chunkId)
+ }
+
+ const list = (nextPage: number, nextQuery = searchDraft.trim()): void => {
+ void onList({
+ documentId,
+ page: nextPage,
+ pageSize: page.pageSize,
+ query: nextQuery
+ })
+ }
+
+ const submitSearch = (event: FormEvent): void => {
+ event.preventDefault()
+ list(1)
+ }
+
+ const updateEnabled = (chunk: KnowledgeManagedChunk, enabled: boolean): void => {
+ void onUpdateChunk(chunk.id, { enabled })
+ }
+
+ const saveContent = (): void => {
+ if (!selectedChunk || contentError || isSavingSelected) {
+ return
+ }
+ void onUpdateChunk(selectedChunk.id, { content: draftContent })
+ }
+
+ const deleteChunk = (chunkId: string): void => {
+ void Promise.resolve(onDeleteChunk(chunkId)).then(
+ () => setConfirmingDeleteId(undefined),
+ () => undefined
+ )
+ }
+
+ return createPortal(
+
+
{
+ if (event.defaultPrevented) {
+ return
+ }
+ if (
+ event.key === 'Escape' &&
+ !savingChunkId &&
+ !deletingChunkId &&
+ !rebuilding
+ ) {
+ event.preventDefault()
+ onClose()
+ return
+ }
+ trapTabFocus(event, dialogRef.current)
+ }}
+ ref={dialogRef}
+ role="dialog"
+ >
+
+
+
+
+
+ {t('chunks.syncWarningTitle')}{' '}
+ {t('chunks.syncWarning')}
+
+
+
+
+
+
+
+ {selectedChunk ? (
+ <>
+
+
+
+ {t('chunks.editor.title', {
+ count: selectedChunk.ordinal
+ })}
+
+
+ {t('chunks.editor.metadata', {
+ role: t(`chunks.roles.${selectedChunk.role}`),
+ locator:
+ selectedChunk.locator ?? t('chunks.unknownLocator')
+ })}
+
+
+ {selectedChunk.manuallyEdited && (
+
+ {t('chunks.editor.manuallyEdited')}
+
+ )}
+
+
+ {selectedChunk.parentChunkId && (
+
+
+
- {t('chunks.editor.role')}
+ - {t(`chunks.roles.${selectedChunk.role}`)}
+
+
+
- {t('chunks.editor.parent')}
+ - {selectedChunk.parentChunkId}
+
+
+ )}
+
+
+ {contentError && (
+
+ {contentError}
+
+ )}
+
+
+ >
+ ) : (
+
+
{t('chunks.editor.noSelectionTitle')}
+
{t('chunks.editor.noSelectionDescription')}
+
+ )}
+
+
+
+
+
+
,
+ document.body
+ )
+}
diff --git a/src/renderer/src/KnowledgeCitationDialog.test.tsx b/src/renderer/src/KnowledgeCitationDialog.test.tsx
new file mode 100644
index 0000000..d935cea
--- /dev/null
+++ b/src/renderer/src/KnowledgeCitationDialog.test.tsx
@@ -0,0 +1,97 @@
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor
+} from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { KnowledgeSearchReference } from '../../shared/contracts'
+import { KnowledgeCitationDialog } from './KnowledgeCitationDialog'
+import i18n from './i18n'
+
+const reference: KnowledgeSearchReference = {
+ libraryId: '11111111-1111-4111-8111-111111111111',
+ libraryName: '产品知识',
+ documentId: '22222222-2222-4222-8222-222222222222',
+ chunkId: '33333333-3333-4333-8333-333333333333',
+ documentName: '发布手册',
+ sourceName: 'release.md',
+ locator: '发布流程',
+ snippet: '先验证,再发布。',
+ rank: -0.03,
+ score: 0.82,
+ retrievalChannels: ['fts', 'vector']
+}
+
+afterEach(() => {
+ cleanup()
+ void i18n.changeLanguage('zh-CN')
+})
+
+describe('KnowledgeCitationDialog', () => {
+ it('shows matched and surrounding context and restores close behavior', async () => {
+ const onClose = vi.fn()
+ const onOpenSource = vi.fn(async () => undefined)
+ render(
+
+ )
+
+ expect(screen.getByRole('dialog', { name: '引用上下文' }))
+ .toBeInTheDocument()
+ expect(screen.getByText('先验证,再发布。')).toBeInTheDocument()
+ expect(
+ screen.getByText('准备发布。先验证,再发布。发布后观察指标。')
+ ).toBeInTheDocument()
+ await waitFor(() =>
+ expect(
+ screen.getAllByRole('button', { name: '关闭引用上下文' })[0]
+ )
+ .toHaveFocus()
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: '打开来源' }))
+ await waitFor(() =>
+ expect(onOpenSource).toHaveBeenCalledWith(reference.documentId)
+ )
+
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('keeps an actionable source-opening error in the dialog', async () => {
+ render(
+ {
+ throw new Error('原文件已移动')
+ }}
+ reference={reference}
+ />
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: '打开来源' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ '原文件已移动'
+ )
+ })
+})
diff --git a/src/renderer/src/KnowledgeCitationDialog.tsx b/src/renderer/src/KnowledgeCitationDialog.tsx
new file mode 100644
index 0000000..f46468b
--- /dev/null
+++ b/src/renderer/src/KnowledgeCitationDialog.tsx
@@ -0,0 +1,196 @@
+import { ExternalLink, FileSearch, LoaderCircle, X } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import type { KnowledgeSearchReference } from '../../shared/contracts'
+import { activateModalFocus, trapTabFocus } from './dialog-focus'
+
+export type KnowledgeCitationContextView = {
+ libraryName: string
+ documentName: string
+ sourceName: string
+ locator?: string
+ matchedContent: string
+ contextContent: string
+ truncated?: boolean
+}
+
+export type KnowledgeCitationDialogProps = {
+ reference: KnowledgeSearchReference
+ context?: KnowledgeCitationContextView
+ loading?: boolean
+ error?: string
+ onClose: () => void
+ onOpenSource: (documentId: string) => void | Promise
+}
+
+export function KnowledgeCitationDialog({
+ reference,
+ context,
+ loading = false,
+ error,
+ onClose,
+ onOpenSource
+}: KnowledgeCitationDialogProps): React.JSX.Element {
+ const { t } = useTranslation('app')
+ const dialogRef = useRef(null)
+ const closeRef = useRef(null)
+ const [opening, setOpening] = useState(false)
+ const [openError, setOpenError] = useState()
+
+ useEffect(() => {
+ return activateModalFocus(() => closeRef.current)
+ }, [])
+
+ const openSource = async (): Promise => {
+ setOpening(true)
+ setOpenError(undefined)
+ try {
+ await onOpenSource(reference.documentId)
+ } catch (reason) {
+ setOpenError(
+ reason instanceof Error
+ ? reason.message
+ : t('chat.citations.contextUnavailable')
+ )
+ } finally {
+ setOpening(false)
+ }
+ }
+
+ return createPortal(
+ {
+ if (event.key === 'Escape' && !opening) {
+ event.preventDefault()
+ onClose()
+ return
+ }
+ trapTabFocus(event, dialogRef.current)
+ }}
+ ref={dialogRef}
+ role="dialog"
+ >
+
+
+
+
+
+
- {reference.documentName}
+ - {context?.sourceName ?? reference.sourceName}
+
+ {(context?.locator ?? reference.locator) && (
+
+
- {context?.locator ?? reference.locator}
+ {reference.score !== undefined && (
+ -
+ {t('chat.citations.score', {
+ score: reference.score.toFixed(3)
+ })}
+
+ )}
+
+ )}
+
+
+ {loading ? (
+
+
+ {t('chat.citations.contextLoading')}
+
+ ) : error ? (
+
+ {error}
+
+ ) : context ? (
+
+
+ {t('chat.citations.matchedChunk')}
+ {context.matchedContent}
+
+
+ {t('chat.citations.surroundingContext')}
+ {context.contextContent}
+ {context.truncated && (
+
+ {t('chat.citations.contextTruncated')}
+
+ )}
+
+
+ ) : (
+
+ {t('chat.citations.contextUnavailable')}
+
+ )}
+
+ {openError && (
+
+ {openError}
+
+ )}
+
+
+
,
+ document.body
+ )
+}
diff --git a/src/renderer/src/KnowledgeEmbeddingIndexSection.test.tsx b/src/renderer/src/KnowledgeEmbeddingIndexSection.test.tsx
new file mode 100644
index 0000000..7d4a9a0
--- /dev/null
+++ b/src/renderer/src/KnowledgeEmbeddingIndexSection.test.tsx
@@ -0,0 +1,114 @@
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen
+} from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type {
+ KnowledgeEmbeddingIndexSnapshot
+} from '../../shared/embedding-contracts'
+import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection'
+
+const snapshot: KnowledgeEmbeddingIndexSnapshot = {
+ knowledgeBaseId: '11111111-1111-4111-8111-111111111111',
+ enabled: true,
+ configuration: {
+ provider: 'openai-compatible',
+ model: 'qwen3-embedding:latest',
+ endpoint: 'http://127.0.0.1:11434/v1/embeddings',
+ credentialConfigured: false
+ },
+ coverage: {
+ total: 12,
+ indexed: 8,
+ missing: 3,
+ error: 1
+ },
+ indexStatus: { job: null }
+}
+
+afterEach(cleanup)
+
+describe('KnowledgeEmbeddingIndexSection', () => {
+ it('shows current-library coverage and starts a scoped rebuild', () => {
+ const onRebuild = vi.fn()
+ render(
+
+ )
+
+ expect(
+ screen.getByRole('heading', { name: '向量索引' })
+ ).toBeInTheDocument()
+ expect(screen.getByText('qwen3-embedding:latest')).toBeInTheDocument()
+ expect(screen.getByText('8')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument()
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('12')).toBeInTheDocument()
+
+ fireEvent.click(
+ screen.getByRole('button', { name: '重建向量索引' })
+ )
+ expect(onRebuild).toHaveBeenCalledOnce()
+ })
+
+ it('routes active job details to the task center without duplicating progress', () => {
+ const onViewTasks = vi.fn()
+ render(
+
+ )
+
+ expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
+ expect(screen.queryByText('已完成 2 / 12 篇文档')).not
+ .toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: '重建向量索引' })
+ ).toBeDisabled()
+
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: '任务中心查看详情'
+ })
+ )
+ expect(onViewTasks).toHaveBeenCalledOnce()
+ })
+
+ it('directs users to model connections when embeddings are disabled', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('向量模型未启用')).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: '重建向量索引' })
+ ).toBeDisabled()
+ })
+})
diff --git a/src/renderer/src/KnowledgeEmbeddingIndexSection.tsx b/src/renderer/src/KnowledgeEmbeddingIndexSection.tsx
new file mode 100644
index 0000000..551d4b6
--- /dev/null
+++ b/src/renderer/src/KnowledgeEmbeddingIndexSection.tsx
@@ -0,0 +1,100 @@
+import { Database, ListChecks, RefreshCw } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import type {
+ KnowledgeEmbeddingIndexSnapshot
+} from '../../shared/embedding-contracts'
+import { isEmbeddingIndexJobActive } from '../../shared/embedding-contracts'
+
+export type KnowledgeEmbeddingIndexSectionProps = {
+ snapshot?: KnowledgeEmbeddingIndexSnapshot
+ loading?: boolean
+ onRebuild: () => void
+ onViewTasks?: () => void
+}
+
+export function KnowledgeEmbeddingIndexSection({
+ snapshot,
+ loading = false,
+ onRebuild,
+ onViewTasks
+}: KnowledgeEmbeddingIndexSectionProps): React.JSX.Element {
+ const { t } = useTranslation('knowledge')
+ const active = isEmbeddingIndexJobActive(snapshot?.indexStatus.job)
+ return (
+
+
+
+
+
+
+ {t('settings.vectorIndex.title')}
+
+
{t('settings.vectorIndex.description')}
+
+
+
+
+
+ {loading || !snapshot ? (
+ {t('settings.vectorIndex.loading')}
+ ) : !snapshot.enabled || !snapshot.configuration ? (
+
+
{t('settings.vectorIndex.disabledTitle')}
+
{t('settings.vectorIndex.disabledDescription')}
+
+ ) : (
+ <>
+
+ {t('settings.vectorIndex.currentModel')}
+ {snapshot.configuration.model}
+
+ {snapshot.configuration.provider}
+ {snapshot.configuration.endpoint
+ ? ` · ${snapshot.configuration.endpoint}`
+ : ''}
+
+
+
+ {(['indexed', 'missing', 'error', 'total'] as const).map(
+ (key) => (
+
+
- {t(`settings.vectorIndex.coverage.${key}`)}
+ - {snapshot.coverage[key]}
+
+ )
+ )}
+
+ {active && (
+
+
+
{t('settings.vectorIndex.activeTitle')}
+
{t('settings.vectorIndex.activeDescription')}
+
+ {onViewTasks && (
+
+ )}
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/renderer/src/KnowledgeGraphChart.tsx b/src/renderer/src/KnowledgeGraphChart.tsx
index 6228a3e..740f943 100644
--- a/src/renderer/src/KnowledgeGraphChart.tsx
+++ b/src/renderer/src/KnowledgeGraphChart.tsx
@@ -33,6 +33,7 @@ type KnowledgeGraphChartProps = {
nodes: readonly ChartKnowledgeGraphNode[]
relations: readonly ChartKnowledgeGraphRelation[]
selectedNodeId?: string
+ fitViewRequest: number
zoom: number
onMoveNode: (nodeId: string, position: { x: number; y: number }) => void
onSelectNode: (nodeId: string) => void
@@ -269,7 +270,10 @@ function createPresentation(
behaviors: [
'drag-canvas',
'zoom-canvas',
- 'drag-element',
+ {
+ type: 'drag-element-force',
+ fixed: true
+ },
{
type: 'auto-adapt-label',
sortNode: { type: 'degree' },
@@ -313,6 +317,7 @@ export function KnowledgeGraphChart({
nodes,
relations,
selectedNodeId,
+ fitViewRequest,
zoom,
onMoveNode,
onSelectNode,
@@ -331,6 +336,7 @@ export function KnowledgeGraphChart({
const relationsRef = useRef(relations)
const selectedNodeIdRef = useRef(selectedNodeId)
const zoomRef = useRef(zoom)
+ const fitViewRequestRef = useRef(fitViewRequest)
const appliedZoomRef = useRef(undefined)
const renderVersionRef = useRef(0)
const renderedRevisionRef = useRef(undefined)
@@ -363,6 +369,44 @@ export function KnowledgeGraphChart({
zoomRef.current = zoom
}, [zoom])
+ useEffect(() => {
+ if (fitViewRequestRef.current === fitViewRequest) {
+ return
+ }
+ fitViewRequestRef.current = fitViewRequest
+ const graph = graphRef.current
+ if (
+ !graph ||
+ renderedRevisionRef.current !== dataRevision
+ ) {
+ return
+ }
+ void graph
+ .fitView(
+ {
+ when: 'always',
+ direction: 'both'
+ },
+ false
+ )
+ .then(() => {
+ if (graphRef.current !== graph) {
+ return
+ }
+ const nextZoom = graph.getZoom()
+ if (Number.isFinite(nextZoom)) {
+ zoomRef.current = nextZoom
+ appliedZoomRef.current = nextZoom
+ onZoomChangeRef.current(nextZoom)
+ }
+ })
+ .catch((error: unknown) => {
+ if (graphRef.current === graph) {
+ setRenderError(graphErrorMessage(error, renderErrorFallback))
+ }
+ })
+ }, [dataRevision, fitViewRequest, renderErrorFallback])
+
useEffect(() => {
if (typeof MutationObserver !== 'function') {
return
diff --git a/src/renderer/src/KnowledgeRetrievalWorkbench.test.tsx b/src/renderer/src/KnowledgeRetrievalWorkbench.test.tsx
new file mode 100644
index 0000000..6edef11
--- /dev/null
+++ b/src/renderer/src/KnowledgeRetrievalWorkbench.test.tsx
@@ -0,0 +1,353 @@
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor
+} from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useState } from 'react'
+import {
+ KnowledgeRetrievalWorkbench,
+ type KnowledgeRetrievalWorkbenchProps,
+ type KnowledgeRetrievalWorkbenchResponse,
+ type KnowledgeRetrievalWorkbenchSettings
+} from './KnowledgeRetrievalWorkbench'
+import { changeUiLocale } from './i18n'
+
+const settings: KnowledgeRetrievalWorkbenchSettings = {
+ topK: 6,
+ minimumVectorSimilarity: 0,
+ ftsWeight: 1,
+ vectorWeight: 1,
+ graphWeight: 0.8,
+ candidateMultiplier: 4,
+ contextMaxCharacters: 16_000,
+ adjacentChunkCount: 1,
+ localRerankEnabled: false,
+ rerankMode: 'none'
+}
+
+const response: KnowledgeRetrievalWorkbenchResponse = {
+ diagnostics: {
+ durationMs: 43,
+ requestedChannels: ['fts', 'cjk', 'vector', 'graph'],
+ usedChannels: ['fts', 'cjk'],
+ degradedChannels: [
+ {
+ channel: 'vector',
+ reason: '查询向量服务不可用'
+ }
+ ],
+ candidateCounts: { fts: 4, cjk: 7, vector: 0, graph: 0 },
+ channelDurationsMs: { fts: 8, cjk: 12, vector: 20, graph: 3 },
+ vectorScannedCount: 10_240
+ },
+ context: {
+ characterCount: 312,
+ budget: 16_000,
+ truncated: false
+ },
+ results: [
+ {
+ chunkId: 'chunk-1',
+ documentId: 'document-1',
+ rank: 1,
+ documentName: '离线部署.md',
+ sourceName: '产品文档',
+ locator: '第 3 节',
+ snippet: '离线环境可以导入已经校验的模型 ZIP。',
+ fusedScore: 0.0328,
+ relevance: 0.91,
+ channels: ['fts', 'cjk'],
+ channelDetails: {
+ fts: { rank: 1, score: 0.84 },
+ cjk: { rank: 2, score: 0.71 }
+ },
+ rankBeforeRerank: 2,
+ contextText: '离线环境可以导入已经校验的模型 ZIP,并在本机运行。',
+ contextCharacterCount: 312,
+ contextTruncated: false,
+ diagnostics: ['标题短语命中']
+ }
+ ]
+}
+
+function createProps(
+ overrides: Partial = {}
+): KnowledgeRetrievalWorkbenchProps {
+ return {
+ libraryName: '产品知识',
+ settings,
+ onTest: vi.fn(),
+ onViewContext: vi.fn(),
+ onOpenSource: vi.fn(),
+ onSaveDefaults: vi.fn(),
+ onClose: vi.fn(),
+ ...overrides
+ }
+}
+
+afterEach(async () => {
+ cleanup()
+ await changeUiLocale('zh-CN')
+})
+
+describe('KnowledgeRetrievalWorkbench', () => {
+ it('provides dialog semantics, focuses the persistent query, and returns focus on close', async () => {
+ function Harness(): React.JSX.Element {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
+
+
+ {open && (
+ setOpen(false) })}
+ />
+ )}
+ >
+ )
+ }
+
+ render()
+ const trigger = screen.getByRole('button', { name: '打开检索测试' })
+ trigger.focus()
+ fireEvent.click(trigger)
+
+ const dialog = screen.getByRole('dialog', { name: '检索测试' })
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
+ expect(screen.getByLabelText('检索问题')).toHaveFocus()
+ expect(
+ document.querySelector('.app-shell')?.inert
+ ).toBe(true)
+
+ fireEvent.change(screen.getByLabelText('检索问题'), {
+ target: { value: '这段查询会被保留' }
+ })
+ expect(screen.getByLabelText('检索问题')).toHaveValue(
+ '这段查询会被保留'
+ )
+
+ fireEvent.keyDown(dialog, { key: 'Escape' })
+
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ )
+ expect(trigger).toHaveFocus()
+ expect(
+ document.querySelector('.app-shell')?.inert
+ ).toBe(false)
+ })
+
+ it('validates the query and settings before invoking retrieval', () => {
+ const onTest = vi.fn()
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
+
+ expect(screen.getByText('请输入测试问题。')).toBeInTheDocument()
+ expect(
+ screen.getByText('Top K 必须是 1 至 20 的整数。')
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('alert')
+ ).toHaveTextContent('至少一个当前可用的检索通道权重必须大于 0。')
+ expect(onTest).not.toHaveBeenCalled()
+ })
+
+ it('requires available channel shares to total one hundred percent', () => {
+ const onTest = vi.fn()
+ render(
+
+ )
+
+ fireEvent.change(screen.getByLabelText(/^全文占比/), {
+ target: { value: '40' }
+ })
+ fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
+
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ '当前可用检索通道的融合占比合计必须为 100%。'
+ )
+ expect(onTest).not.toHaveBeenCalled()
+ })
+
+ it('presents legacy negative thresholds as zero', () => {
+ const onTest = vi.fn()
+ render(
+
+ )
+
+ expect(screen.getByLabelText(/^最低向量相似度/)).toHaveValue(0)
+ fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
+ expect(onTest).toHaveBeenCalledWith({
+ query: '兼容旧阈值',
+ settings: {
+ ...settings,
+ minimumVectorSimilarity: 0
+ }
+ })
+ })
+
+ it('submits temporary settings and exposes result actions and diagnostics', () => {
+ const onTest = vi.fn()
+ const onSaveDefaults = vi.fn()
+ const onViewContext = vi.fn()
+ const onOpenSource = vi.fn()
+ render(
+
+ )
+
+ expect(screen.getByText('最多 24 个融合候选')).toBeInTheDocument()
+ const recallMultiplier = screen.getByLabelText(/^召回倍数/)
+ fireEvent.change(recallMultiplier, { target: { value: '5' } })
+ expect(screen.getByText('最多 30 个融合候选')).toBeInTheDocument()
+ expect(screen.getByLabelText(/^全文占比/)).toHaveValue(35.7)
+ expect(screen.getByLabelText(/^向量占比/)).toHaveValue(35.7)
+ expect(screen.getByLabelText(/^图谱占比/)).toHaveValue(28.6)
+ fireEvent.change(screen.getByLabelText(/^最低向量相似度/), {
+ target: { value: '25' }
+ })
+ fireEvent.change(screen.getByLabelText(/^全文占比/), {
+ target: { value: '50' }
+ })
+ fireEvent.change(screen.getByLabelText(/^向量占比/), {
+ target: { value: '30' }
+ })
+ fireEvent.change(screen.getByLabelText(/^图谱占比/), {
+ target: { value: '20' }
+ })
+ const topK = screen.getByLabelText(/^最终结果数/)
+ fireEvent.change(topK, { target: { value: '8' } })
+ expect(screen.getByText('最多 40 个融合候选')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '本地规则' }))
+ expect(screen.getByText('重排最多 40 个候选')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
+
+ expect(onTest).toHaveBeenCalledWith({
+ query: '如何离线部署?',
+ settings: {
+ ...settings,
+ topK: 8,
+ candidateMultiplier: 5,
+ minimumVectorSimilarity: 0.25,
+ ftsWeight: 1,
+ vectorWeight: 0.6,
+ graphWeight: 0.4,
+ localRerankEnabled: true,
+ rerankMode: 'local'
+ }
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: '保存为默认值' }))
+ expect(onSaveDefaults).toHaveBeenCalledWith({
+ ...settings,
+ topK: 8,
+ candidateMultiplier: 5,
+ minimumVectorSimilarity: 0.25,
+ ftsWeight: 1,
+ vectorWeight: 0.6,
+ graphWeight: 0.4,
+ localRerankEnabled: true,
+ rerankMode: 'local'
+ })
+
+ expect(screen.getByText('本次检索已降级')).toBeInTheDocument()
+ expect(screen.getByText('已扫描向量')).toBeInTheDocument()
+ expect(screen.getByText('91%')).toBeInTheDocument()
+ expect(
+ screen.getByRole('article', {
+ name: '第 1 条结果,离线部署.md'
+ })
+ ).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: '查看分块' }))
+ fireEvent.click(screen.getByRole('button', { name: '打开来源' }))
+ expect(onViewContext).toHaveBeenCalledWith(response.results[0])
+ expect(onOpenSource).toHaveBeenCalledWith(response.results[0])
+ })
+
+ it('distinguishes running, error, and zero-result states', () => {
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByRole('status')).toHaveTextContent(
+ '正在检索当前知识库'
+ )
+ expect(screen.getByRole('button', { name: '正在检索…' })).toBeDisabled()
+
+ rerender(
+
+ )
+ expect(screen.getByRole('alert')).toHaveTextContent('索引校验失败')
+
+ rerender(
+
+ )
+ expect(screen.getByText('结果已被阈值过滤')).toBeInTheDocument()
+ expect(screen.getByLabelText('检索问题')).toHaveValue('没有答案的问题')
+ })
+})
diff --git a/src/renderer/src/KnowledgeRetrievalWorkbench.tsx b/src/renderer/src/KnowledgeRetrievalWorkbench.tsx
new file mode 100644
index 0000000..0afc352
--- /dev/null
+++ b/src/renderer/src/KnowledgeRetrievalWorkbench.tsx
@@ -0,0 +1,1127 @@
+import {
+ AlertTriangle,
+ ExternalLink,
+ FileSearch,
+ Save,
+ Search,
+ X
+} from 'lucide-react'
+import {
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+ type FormEvent
+} from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import {
+ defaultKnowledgeRetrievalSettings,
+ knowledgeRetrievalSettingsSchema,
+ type KnowledgeRetrievalChannel as SharedKnowledgeRetrievalChannel,
+ type KnowledgeRetrievalSettings
+} from '../../shared/knowledge-contracts'
+import { activateModalFocus, trapTabFocus } from './dialog-focus'
+import { SegmentedControl } from './WorkspacePrimitives'
+
+export type KnowledgeRetrievalChannel =
+ SharedKnowledgeRetrievalChannel
+
+export type KnowledgeRetrievalWorkbenchSettings = Omit<
+ KnowledgeRetrievalSettings,
+ 'version'
+>
+
+export type KnowledgeRetrievalChannelDetail = {
+ rank?: number
+ score?: number
+ similarity?: number
+}
+
+export type KnowledgeRetrievalWorkbenchResult = {
+ chunkId: string
+ documentId: string
+ rank: number
+ documentName: string
+ sourceName: string
+ locator?: string
+ snippet: string
+ fusedScore?: number
+ relevance?: number
+ channels: readonly KnowledgeRetrievalChannel[]
+ channelDetails?: Partial<
+ Record
+ >
+ rankBeforeRerank?: number
+ contextText?: string
+ contextCharacterCount?: number
+ contextTruncated?: boolean
+ diagnostics?: readonly string[]
+}
+
+export type KnowledgeRetrievalDiagnostics = {
+ durationMs: number
+ requestedChannels: readonly KnowledgeRetrievalChannel[]
+ usedChannels: readonly KnowledgeRetrievalChannel[]
+ degradedChannels?: readonly {
+ channel: KnowledgeRetrievalChannel
+ reason: string
+ }[]
+ candidateCounts?: Partial>
+ channelDurationsMs?: Partial>
+ vectorScannedCount?: number
+ rerank?: {
+ requested: 'none' | 'local' | 'learned'
+ used: 'none' | 'local' | 'learned'
+ status: 'skipped' | 'applied' | 'fallback' | 'failed'
+ candidateCount: number
+ durationMs: number
+ model?: string
+ reason?: string
+ }
+}
+
+export type KnowledgeRetrievalContextSummary = {
+ characterCount: number
+ budget: number
+ truncated: boolean
+}
+
+export type KnowledgeRetrievalZeroReason =
+ | 'empty-library'
+ | 'index-unavailable'
+ | 'no-match'
+ | 'filtered'
+
+export type KnowledgeRetrievalWorkbenchResponse = {
+ diagnostics: KnowledgeRetrievalDiagnostics
+ results: readonly KnowledgeRetrievalWorkbenchResult[]
+ context: KnowledgeRetrievalContextSummary
+ zeroReason?: KnowledgeRetrievalZeroReason
+}
+
+export type KnowledgeRetrievalWorkbenchProps = {
+ libraryName: string
+ initialQuery?: string
+ settings: KnowledgeRetrievalWorkbenchSettings
+ graphAvailable?: boolean
+ status?: 'idle' | 'running' | 'error' | 'success'
+ error?: string
+ response?: KnowledgeRetrievalWorkbenchResponse
+ savingDefaults?: boolean
+ onTest: (request: {
+ query: string
+ settings: KnowledgeRetrievalWorkbenchSettings
+ }) => void | Promise
+ onViewContext: (result: KnowledgeRetrievalWorkbenchResult) => void
+ onOpenSource: (result: KnowledgeRetrievalWorkbenchResult) => void
+ onSaveDefaults: (
+ settings: KnowledgeRetrievalWorkbenchSettings
+ ) => void | Promise
+ onClose: () => void
+}
+
+type ValidationErrors = Partial<
+ Record
+>
+
+const channels: readonly KnowledgeRetrievalChannel[] = [
+ 'fts',
+ 'cjk',
+ 'vector',
+ 'graph'
+]
+
+const weightKeys = [
+ 'ftsWeight',
+ 'vectorWeight',
+ 'graphWeight'
+] as const
+type WeightKey = (typeof weightKeys)[number]
+type WeightPercentageInputs = Record
+
+function formatScore(value: number | undefined): string {
+ return value === undefined ? '—' : value.toFixed(4)
+}
+
+function percentageInput(value: number): string {
+ const rounded = Math.round(value * 10) / 10
+ return String(Object.is(rounded, -0) ? 0 : rounded)
+}
+
+function activeWeightKeys(graphAvailable: boolean): readonly WeightKey[] {
+ return graphAvailable
+ ? weightKeys
+ : ['ftsWeight', 'vectorWeight']
+}
+
+function initialWeightPercentages(
+ settings: KnowledgeRetrievalWorkbenchSettings,
+ graphAvailable: boolean
+): WeightPercentageInputs {
+ const activeKeys = activeWeightKeys(graphAvailable)
+ const values = activeKeys.map((key) => settings[key])
+ const total = values.every(
+ (value) => Number.isFinite(value) && value >= 0
+ )
+ ? values.reduce((sum, value) => sum + value, 0)
+ : 0
+ const percentages: WeightPercentageInputs = {
+ ftsWeight: '0',
+ vectorWeight: '0',
+ graphWeight: ''
+ }
+ if (total <= 0) {
+ for (const key of activeKeys) {
+ percentages[key] = '0'
+ }
+ return percentages
+ }
+ let assignedTenths = 0
+ activeKeys.forEach((key, index) => {
+ const tenths =
+ index === activeKeys.length - 1
+ ? 1_000 - assignedTenths
+ : Math.round((settings[key] / total) * 1_000)
+ assignedTenths += tenths
+ percentages[key] = percentageInput(tenths / 10)
+ })
+ return percentages
+}
+
+function validate(
+ query: string,
+ settings: KnowledgeRetrievalWorkbenchSettings,
+ weightPercentages: WeightPercentageInputs,
+ graphAvailable: boolean,
+ t: (key: string, options?: Record) => string
+): ValidationErrors {
+ const errors: ValidationErrors = {}
+ const length = query.trim().length
+ if (length === 0) {
+ errors.query = t('retrieval.validation.queryRequired')
+ } else if (query.length > 4_000) {
+ errors.query = t('retrieval.validation.queryTooLong')
+ }
+ const parsedSettings = knowledgeRetrievalSettingsSchema.safeParse({
+ ...defaultKnowledgeRetrievalSettings,
+ ...settings
+ })
+ if (!parsedSettings.success) {
+ for (const issue of parsedSettings.error.issues) {
+ const key = issue.path[0]
+ if (key === 'topK') {
+ errors.topK = t('retrieval.validation.topK')
+ } else if (key === 'candidateMultiplier') {
+ errors.candidateMultiplier = t(
+ 'retrieval.validation.candidateMultiplier'
+ )
+ } else if (key === 'minimumVectorSimilarity') {
+ errors.minimumVectorSimilarity = t(
+ 'retrieval.validation.vectorSimilarity'
+ )
+ } else if (
+ key === 'ftsWeight' ||
+ key === 'vectorWeight' ||
+ key === 'graphWeight'
+ ) {
+ errors[key] = t('retrieval.validation.weight')
+ } else if (key === 'contextMaxCharacters') {
+ errors.contextMaxCharacters = t(
+ 'retrieval.validation.contextBudget'
+ )
+ } else if (key === 'adjacentChunkCount') {
+ errors.adjacentChunkCount = t(
+ 'retrieval.validation.adjacentCount'
+ )
+ } else if (issue.path.length === 0) {
+ errors.weights = t('retrieval.validation.activeWeight')
+ }
+ }
+ }
+ const activeWeight =
+ settings.ftsWeight +
+ settings.vectorWeight +
+ (graphAvailable ? settings.graphWeight : 0)
+ for (const key of activeWeightKeys(graphAvailable)) {
+ const percentage = Number(weightPercentages[key])
+ if (
+ weightPercentages[key] === '' ||
+ !Number.isFinite(percentage) ||
+ percentage < 0 ||
+ percentage > 100
+ ) {
+ errors[key] = t('retrieval.validation.weight')
+ }
+ }
+ const percentageTotal = activeWeightKeys(graphAvailable).reduce(
+ (sum, key) => sum + Number(weightPercentages[key]),
+ 0
+ )
+ if (
+ Number.isFinite(percentageTotal) &&
+ Math.abs(percentageTotal - 100) > 0.11
+ ) {
+ errors.weights = t('retrieval.validation.weightTotal')
+ }
+ if (activeWeight <= 0) {
+ errors.weights = t('retrieval.validation.activeWeight')
+ }
+ return errors
+}
+
+export function KnowledgeRetrievalWorkbench({
+ error,
+ graphAvailable = true,
+ initialQuery = '',
+ libraryName,
+ onClose,
+ onOpenSource,
+ onSaveDefaults,
+ onTest,
+ onViewContext,
+ response,
+ savingDefaults = false,
+ settings,
+ status = 'idle'
+}: KnowledgeRetrievalWorkbenchProps): React.JSX.Element {
+ const { i18n, t } = useTranslation('knowledge')
+ const [query, setQuery] = useState(initialQuery)
+ const [draftSettings, setDraftSettings] = useState(() => ({
+ ...settings,
+ minimumVectorSimilarity: Math.max(
+ 0,
+ settings.minimumVectorSimilarity
+ )
+ }))
+ const [vectorSimilarityPercent, setVectorSimilarityPercent] = useState(
+ percentageInput(Math.max(0, settings.minimumVectorSimilarity) * 100)
+ )
+ const [weightPercentages, setWeightPercentages] =
+ useState(() =>
+ initialWeightPercentages(settings, graphAvailable)
+ )
+ const [showQueryValidation, setShowQueryValidation] = useState(false)
+ const [showSettingsValidation, setShowSettingsValidation] =
+ useState(false)
+ const dialogRef = useRef(null)
+ const queryRef = useRef(null)
+ const titleId = useId()
+ const descriptionId = useId()
+ const queryErrorId = useId()
+ const settingsErrorId = useId()
+ const percentFormatter = useMemo(
+ () =>
+ new Intl.NumberFormat(i18n.resolvedLanguage || i18n.language, {
+ style: 'percent',
+ maximumFractionDigits: 1
+ }),
+ [i18n.language, i18n.resolvedLanguage]
+ )
+
+ useEffect(() => {
+ return activateModalFocus(() => queryRef.current)
+ }, [])
+
+ const validationErrors = useMemo(
+ () =>
+ validate(
+ query,
+ draftSettings,
+ weightPercentages,
+ graphAvailable,
+ t
+ ),
+ [draftSettings, graphAvailable, query, t, weightPercentages]
+ )
+ const hasValidationErrors = Object.keys(validationErrors).length > 0
+ const hasSettingsValidationErrors = Object.entries(validationErrors).some(
+ ([key]) => key !== 'query'
+ )
+ const running = status === 'running'
+ const candidateLimit =
+ Number.isSafeInteger(draftSettings.topK) &&
+ Number.isSafeInteger(draftSettings.candidateMultiplier) &&
+ draftSettings.topK > 0 &&
+ draftSettings.candidateMultiplier > 0
+ ? Math.min(
+ 100,
+ draftSettings.topK * draftSettings.candidateMultiplier
+ )
+ : undefined
+
+ const updateNumber = (
+ key: keyof KnowledgeRetrievalWorkbenchSettings,
+ value: string
+ ): void => {
+ setDraftSettings((current) => ({
+ ...current,
+ [key]: value === '' ? Number.NaN : Number(value)
+ }))
+ }
+
+ const updateVectorSimilarity = (value: string): void => {
+ setVectorSimilarityPercent(value)
+ setDraftSettings((current) => ({
+ ...current,
+ minimumVectorSimilarity:
+ value === '' ? Number.NaN : Number(value) / 100
+ }))
+ }
+
+ const updateWeight = (key: WeightKey, value: string): void => {
+ setWeightPercentages((current) => ({
+ ...current,
+ [key]: value
+ }))
+ setDraftSettings((current) => ({
+ ...current,
+ [key]: value === '' ? Number.NaN : Number(value) / 50
+ }))
+ }
+
+ const formatPercent = (value: number | undefined): string =>
+ value === undefined ? '—' : percentFormatter.format(value)
+
+ const submitTest = (event: FormEvent): void => {
+ event.preventDefault()
+ setShowQueryValidation(true)
+ setShowSettingsValidation(true)
+ if (hasValidationErrors || running) {
+ return
+ }
+ void onTest({ query: query.trim(), settings: draftSettings })
+ }
+
+ const saveDefaults = (): void => {
+ setShowSettingsValidation(true)
+ if (hasSettingsValidationErrors || savingDefaults) {
+ return
+ }
+ void onSaveDefaults(draftSettings)
+ }
+
+ const channelLabel = (channel: KnowledgeRetrievalChannel): string =>
+ t(`retrieval.channels.${channel}`)
+
+ const results = response?.results ?? []
+
+ return createPortal(
+
+
{
+ if (event.defaultPrevented) {
+ return
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ onClose()
+ return
+ }
+ trapTabFocus(event, dialogRef.current)
+ }}
+ ref={dialogRef}
+ role="dialog"
+ >
+
+
+
+
+
,
+ document.body
+ )
+}
diff --git a/src/renderer/src/KnowledgeWorkspace.test.tsx b/src/renderer/src/KnowledgeWorkspace.test.tsx
index 8b3ef69..5730c5c 100644
--- a/src/renderer/src/KnowledgeWorkspace.test.tsx
+++ b/src/renderer/src/KnowledgeWorkspace.test.tsx
@@ -13,6 +13,9 @@ import {
type KnowledgeWorkspaceProps
} from './KnowledgeWorkspace'
import i18n from './i18n'
+import {
+ defaultKnowledgeOntologySettings
+} from '../../shared/knowledge-ontology'
const g6Mock = vi.hoisted(() => {
const handlers = new Map void>()
@@ -66,6 +69,7 @@ const library: KnowledgeWorkspaceProps['libraries'][number] = {
sourceCount: 1,
documentCount: 1,
indexedDocumentCount: 1,
+ ontologySettings: defaultKnowledgeOntologySettings,
updatedAt: '2026-07-30T08:00:00.000Z'
}
@@ -148,6 +152,85 @@ function createProps(
onPauseSource: vi.fn(),
onRetrySource: vi.fn(),
onRemoveSource: vi.fn(),
+ onRetrieve: vi.fn(async () => ({
+ query: 'test',
+ durationMs: 0,
+ settings: {
+ version: 1 as const,
+ topK: 6,
+ minimumVectorSimilarity: 0,
+ ftsWeight: 1,
+ vectorWeight: 1,
+ graphWeight: 0.8,
+ candidateMultiplier: 4,
+ contextMaxCharacters: 16_000,
+ adjacentChunkCount: 0,
+ localRerankEnabled: false,
+ rerankMode: 'none' as const
+ },
+ diagnostics: {
+ requestedChannels: [],
+ usedChannels: [],
+ degradedChannels: [],
+ candidateCounts: {},
+ channelDurationMs: {},
+ vectorScannedCount: 0,
+ filteredByThresholdCount: 0,
+ filteredByBudgetCount: 0,
+ rerank: {
+ requested: 'none' as const,
+ used: 'none' as const,
+ status: 'skipped' as const,
+ candidateCount: 0,
+ durationMs: 0
+ }
+ },
+ results: [],
+ context: {
+ characterCount: 0,
+ truncated: false,
+ groups: []
+ }
+ })),
+ onUpdateKnowledgeSettings: vi.fn(),
+ onListChunks: vi.fn(async () => ({
+ items: [],
+ page: 1,
+ pageSize: 50,
+ totalItems: 0
+ })),
+ onUpdateChunk: vi.fn(),
+ onDeleteChunk: vi.fn(),
+ onRebuildDocument: vi.fn(),
+ onRebuildLibrary: vi.fn(),
+ onCancelRebuild: vi.fn(),
+ onGetEmbeddingIndex: vi.fn(async () => ({
+ knowledgeBaseId: library.id,
+ enabled: true,
+ configuration: {
+ provider: 'openai-compatible',
+ model: 'nomic-embed-text',
+ endpoint: 'http://127.0.0.1:11434/v1/embeddings',
+ credentialConfigured: false
+ },
+ coverage: { total: 1, indexed: 1, missing: 0, error: 0 },
+ indexStatus: { job: null }
+ })),
+ onRebuildEmbeddingIndex: vi.fn(async () => ({
+ knowledgeBaseId: library.id,
+ enabled: true,
+ configuration: {
+ provider: 'openai-compatible',
+ model: 'nomic-embed-text',
+ endpoint: 'http://127.0.0.1:11434/v1/embeddings',
+ credentialConfigured: false
+ },
+ coverage: { total: 1, indexed: 1, missing: 0, error: 0 },
+ indexStatus: { job: null }
+ })),
+ onCancelTask: vi.fn(),
+ onRetryTask: vi.fn(),
+ onOpenReferenceSource: vi.fn(),
onMoveNode: vi.fn(),
onCreateEntity: vi.fn(),
onUpdateEntity: vi.fn(),
@@ -188,10 +271,10 @@ describe('KnowledgeWorkspace', () => {
fireEvent.click(screen.getByLabelText(/引用原文件/))
expect(
screen.getByRole('switch', { name: /启用知识图谱/u })
- ).toBeChecked()
- fireEvent.change(screen.getByLabelText('图谱生成策略'), {
- target: { value: 'rules' }
- })
+ ).not.toBeChecked()
+ expect(
+ screen.queryByLabelText('图谱生成策略')
+ ).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '创建知识库' }))
await waitFor(() =>
@@ -199,7 +282,7 @@ describe('KnowledgeWorkspace', () => {
name: '客户研究',
description: '访谈与反馈',
storageMode: 'reference',
- graphEnabled: true,
+ graphEnabled: false,
graphStrategy: 'rules'
})
)
@@ -243,6 +326,140 @@ describe('KnowledgeWorkspace', () => {
)
})
+ it('opens the retrieval workbench and runs an isolated test query', async () => {
+ const props = createProps()
+ const onRetrieve = vi.fn(props.onRetrieve)
+ render(
+
+ )
+
+ fireEvent.click(
+ screen.getByRole('button', { name: '检索测试' })
+ )
+ const dialog = screen.getByRole('dialog', { name: '检索测试' })
+ fireEvent.change(within(dialog).getByLabelText('检索问题'), {
+ target: { value: '如何配置离线部署?' }
+ })
+ fireEvent.click(
+ within(dialog).getByRole('button', { name: '测试检索' })
+ )
+
+ await waitFor(() =>
+ expect(onRetrieve).toHaveBeenCalledWith(
+ 'library-1',
+ '如何配置离线部署?',
+ expect.objectContaining({ topK: 6 })
+ )
+ )
+ })
+
+ it('opens document chunk management from a ready document row', async () => {
+ const onListChunks = vi.fn(async () => ({
+ items: [],
+ page: 1,
+ pageSize: 50,
+ totalItems: 0
+ }))
+ render(
+
+ )
+ fireEvent.click(
+ screen.getByRole('button', { name: '文档分块' })
+ )
+ expect(
+ screen.getByRole('dialog', { name: '文档分块' })
+ ).toBeInTheDocument()
+ await waitFor(() =>
+ expect(onListChunks).toHaveBeenCalledWith({
+ libraryId: 'library-1',
+ documentId: 'document-1',
+ page: 1,
+ pageSize: 50,
+ search: undefined
+ })
+ )
+ })
+
+ it('ignores stale chunk responses after opening another document', async () => {
+ let resolveFirst:
+ | ((value: Awaited<
+ ReturnType
+ >) => void)
+ | undefined
+ const secondDocument = {
+ ...createProps().documents[0]!,
+ id: 'document-2',
+ name: '第二份文档.md'
+ }
+ const onListChunks = vi.fn(
+ (input: Parameters[0]) => {
+ if (input.documentId === 'document-1') {
+ return new Promise<
+ Awaited>
+ >((resolve) => {
+ resolveFirst = resolve
+ })
+ }
+ return Promise.resolve({
+ items: [
+ {
+ id: 'second-chunk',
+ ordinal: 0,
+ content: '第二份文档内容',
+ characterCount: 7,
+ enabled: true,
+ role: 'standalone' as const,
+ manuallyEdited: false
+ }
+ ],
+ page: 1,
+ pageSize: 50,
+ totalItems: 1
+ })
+ }
+ )
+ render(
+
+ )
+
+ const chunkButtons = screen.getAllByRole('button', { name: '文档分块' })
+ fireEvent.click(chunkButtons[0]!)
+ fireEvent.click(screen.getByRole('button', { name: '关闭文档分块' }))
+ fireEvent.click(chunkButtons[1]!)
+ expect(await screen.findByText('第二份文档内容')).toBeInTheDocument()
+
+ await act(async () => {
+ resolveFirst?.({
+ items: [
+ {
+ id: 'stale-chunk',
+ ordinal: 0,
+ content: '过期文档内容',
+ characterCount: 6,
+ enabled: true,
+ role: 'standalone',
+ manuallyEdited: false
+ }
+ ],
+ page: 1,
+ pageSize: 50,
+ totalItems: 1
+ })
+ })
+ expect(screen.queryByText('过期文档内容')).not.toBeInTheDocument()
+ expect(screen.getByText('第二份文档内容')).toBeInTheDocument()
+ })
+
it('switches to the graph and opens entity details', () => {
render()
@@ -264,24 +481,328 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
})
- it('uses shared tabs and keeps graph configuration in settings', () => {
+ it('uses controlled localized ontology types and endpoint constraints', async () => {
+ const onCreateEntity = vi.fn()
+ const onCreateRelation = vi.fn()
+ const ontologySettings = {
+ version: 1 as const,
+ entityTypes: [
+ {
+ id: 'CONCEPT',
+ name: { zh: '概念', en: 'Concept' },
+ aliases: ['概念', '产品', '技术']
+ },
+ {
+ id: 'PERSON',
+ name: { zh: '人物', en: 'Person' },
+ aliases: ['人物']
+ }
+ ],
+ relationTypes: [
+ {
+ id: 'RELATED_TO',
+ name: { zh: '相关', en: 'Related to' },
+ aliases: ['相关'],
+ sourceTypes: ['CONCEPT'],
+ targetTypes: ['CONCEPT']
+ },
+ {
+ id: 'KNOWS',
+ name: { zh: '认识', en: 'Knows' },
+ aliases: ['认识'],
+ sourceTypes: ['PERSON'],
+ targetTypes: ['PERSON']
+ }
+ ]
+ }
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
+ fireEvent.click(screen.getByRole('button', { name: '新增实体' }))
+ const entityForm = screen.getByRole('form', { name: '新增实体' })
+ expect(within(entityForm).getByLabelText('类型').tagName).toBe('SELECT')
+ expect(
+ within(entityForm).getByRole('option', { name: '概念 (CONCEPT)' })
+ ).toBeInTheDocument()
+
+ fireEvent.change(within(entityForm).getByLabelText('名称'), {
+ target: { value: '新概念' }
+ })
+ fireEvent.click(within(entityForm).getByRole('button', {
+ name: '新增实体'
+ }))
+ await waitFor(() =>
+ expect(onCreateEntity).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'CONCEPT' })
+ )
+ )
+
+ fireEvent.change(screen.getByLabelText('选择图谱实体'), {
+ target: { value: 'entity-1' }
+ })
+ fireEvent.click(screen.getByRole('button', { name: '新增' }))
+ const relationForm = screen.getByRole('form', { name: '新增关系' })
+ expect(
+ within(relationForm).getByRole('option', {
+ name: '相关 (RELATED_TO)'
+ })
+ ).toBeInTheDocument()
+ expect(
+ within(relationForm).queryByRole('option', { name: '认识 (KNOWS)' })
+ ).not.toBeInTheDocument()
+ })
+
+ it('uses capability-aware tabs and keeps index controls separate', async () => {
const onUpdateLibrary = vi.fn()
- render()
+ const onRebuildEmbeddingIndex = vi.fn(async () => ({
+ knowledgeBaseId: library.id,
+ enabled: true,
+ configuration: {
+ provider: 'openai-compatible',
+ model: 'nomic-embed-text',
+ credentialConfigured: false
+ },
+ coverage: { total: 1, indexed: 1, missing: 0, error: 0 },
+ indexStatus: { job: null }
+ }))
+ render(
+
+ )
const tabs = screen.getByRole('tablist', { name: '知识库视图' })
expect(within(tabs).getAllByRole('tab').map((item) => item.textContent))
- .toEqual(['文档与来源', '知识图谱', '任务中心', '设置'])
+ .toEqual(['文档与来源', '知识图谱', '任务中心', '索引与检索'])
expect(
screen.queryByRole('switch', { name: '知识图谱' })
).not.toBeInTheDocument()
- fireEvent.click(screen.getByRole('tab', { name: '设置' }))
+ fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
+ expect(
+ await screen.findByRole('heading', { name: '向量索引' })
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('heading', { name: '本体定义' })
+ ).not.toBeInTheDocument()
+ expect(screen.queryByLabelText('知识图谱抽取策略'))
+ .not.toBeInTheDocument()
+ fireEvent.click(
+ screen.getByRole('button', { name: '重建向量索引' })
+ )
+ expect(onRebuildEmbeddingIndex).toHaveBeenCalledWith(library.id)
fireEvent.click(screen.getByRole('switch', { name: /启用知识图谱/u }))
expect(onUpdateLibrary).toHaveBeenCalledWith('library-1', {
graphEnabled: false
})
})
+ it('hides graph navigation and graph-only controls until enabled', () => {
+ const disabledLibrary = {
+ ...library,
+ graphEnabled: false
+ }
+ render(
+
+ )
+
+ const tabs = screen.getByRole('tablist', { name: '知识库视图' })
+ expect(within(tabs).getAllByRole('tab').map((item) => item.textContent))
+ .toEqual(['文档与来源', '任务中心', '索引与检索'])
+ expect(screen.queryByText('本次导入的图谱抽取策略'))
+ .not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
+ expect(screen.getByRole('switch', { name: /启用知识图谱/u }))
+ .not.toBeChecked()
+ expect(screen.queryByLabelText('知识图谱抽取策略'))
+ .not.toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: '本体定义' }))
+ .not.toBeInTheDocument()
+ })
+
+ it('moves graph configuration into the enabled graph workspace', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
+ const graphTabs = screen.getByRole('tablist', {
+ name: '知识图谱工作区'
+ })
+ expect(within(graphTabs).getAllByRole('tab').map((item) => item.textContent))
+ .toEqual(['图谱探索', '图谱设置'])
+ fireEvent.click(within(graphTabs).getByRole('tab', { name: '图谱设置' }))
+
+ expect(screen.getByLabelText('知识图谱抽取策略')).toBeInTheDocument()
+ expect(
+ screen.getByRole('heading', { name: '本体定义' })
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('heading', { name: '向量索引' })
+ ).not.toBeInTheDocument()
+ })
+
+ it('ignores stale vector status after switching libraries', async () => {
+ let resolveFirst:
+ | ((value: Awaited<
+ ReturnType
+ >) => void)
+ | undefined
+ const secondLibrary = {
+ ...library,
+ id: 'library-2',
+ name: '客户知识'
+ }
+ const onGetEmbeddingIndex = vi.fn((libraryId: string) => {
+ if (libraryId === library.id) {
+ return new Promise<
+ Awaited<
+ ReturnType
+ >
+ >((resolve) => {
+ resolveFirst = resolve
+ })
+ }
+ return Promise.resolve({
+ knowledgeBaseId: secondLibrary.id,
+ enabled: true,
+ configuration: {
+ provider: 'openai-compatible',
+ model: 'second-model',
+ credentialConfigured: false
+ },
+ coverage: { total: 2, indexed: 2, missing: 0, error: 0 },
+ indexStatus: { job: null }
+ })
+ })
+ const props = createProps({
+ libraries: [library, secondLibrary],
+ onGetEmbeddingIndex
+ })
+ const { rerender } = render()
+
+ fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
+ rerender(
+
+ )
+ expect(await screen.findByText('second-model')).toBeInTheDocument()
+
+ await act(async () => {
+ resolveFirst?.({
+ knowledgeBaseId: library.id,
+ enabled: true,
+ configuration: {
+ provider: 'openai-compatible',
+ model: 'stale-first-model',
+ credentialConfigured: false
+ },
+ coverage: { total: 1, indexed: 1, missing: 0, error: 0 },
+ indexStatus: { job: null }
+ })
+ })
+ expect(screen.queryByText('stale-first-model')).not.toBeInTheDocument()
+ expect(screen.getByText('second-model')).toBeInTheDocument()
+ })
+
+ it('saves parent-child chunking settings without rebuilding implicitly', async () => {
+ const onUpdateKnowledgeSettings = vi.fn()
+ const onRebuildLibrary = vi.fn()
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
+ fireEvent.change(screen.getByLabelText('分块方式'), {
+ target: { value: 'parent-child' }
+ })
+ fireEvent.click(
+ screen.getByRole('button', { name: '保存分块设置' })
+ )
+
+ await waitFor(() =>
+ expect(onUpdateKnowledgeSettings).toHaveBeenCalledWith(
+ 'library-1',
+ {
+ chunking: expect.objectContaining({
+ mode: 'parent-child',
+ parentCharacters: 4_800,
+ childCharacters: 900
+ })
+ }
+ )
+ )
+ expect(onRebuildLibrary).not.toHaveBeenCalled()
+ })
+
+ it('saves library ontology definitions without rebuilding implicitly', async () => {
+ const onUpdateKnowledgeSettings = vi.fn()
+ const onRebuildLibrary = vi.fn()
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
+ fireEvent.click(
+ screen.getByRole('tab', { name: '图谱设置' })
+ )
+ const ontologySection = screen
+ .getByRole('heading', { name: '本体定义' })
+ .closest('section')!
+ const chineseNameInputs = within(ontologySection).getAllByLabelText(
+ '中文名称'
+ )
+ fireEvent.change(chineseNameInputs[0]!, {
+ target: { value: '人员' }
+ })
+ fireEvent.click(
+ within(ontologySection).getByRole('button', {
+ name: '保存本体定义'
+ })
+ )
+
+ await waitFor(() =>
+ expect(onUpdateKnowledgeSettings).toHaveBeenCalledWith(
+ 'library-1',
+ {
+ ontology: expect.objectContaining({
+ entityTypes: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'PERSON',
+ name: expect.objectContaining({ zh: '人员' })
+ })
+ ])
+ })
+ }
+ )
+ )
+ expect(onRebuildLibrary).not.toHaveBeenCalled()
+ })
+
it('shows parsing, embedding, and graph progress in the task center', () => {
render(
{
libraryId: 'library-1',
documentId: 'document-1',
documentName: '架构说明.md',
+ scope: 'document',
kind: 'graph',
+ stage: 'graph',
status: 'running',
progress: 40,
message: '正在重新抽取知识图谱',
+ attempt: 1,
+ canCancel: true,
+ canRetry: false,
createdAt: '2026-08-10T08:00:00.000Z',
- startedAt: '2026-08-10T08:00:01.000Z'
+ startedAt: '2026-08-10T08:00:01.000Z',
+ updatedAt: '2026-08-10T08:00:02.000Z'
}
]
})}
@@ -316,6 +843,343 @@ describe('KnowledgeWorkspace', () => {
).toHaveValue(40)
})
+ it('filters tasks and discloses parent stages with errors and actions', () => {
+ const onCancelTask = vi.fn()
+ const onRetryTask = vi.fn()
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: '任务中心' }))
+ const disclosure = screen.getByRole('button', {
+ name: '展开 整库重建 的阶段任务'
+ })
+ expect(disclosure).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByText('向量服务不可用')).not.toBeInTheDocument()
+ fireEvent.click(disclosure)
+ expect(disclosure).toHaveAttribute('aria-expanded', 'true')
+ expect(screen.getByText('生成向量')).toBeInTheDocument()
+ expect(screen.getByText('向量服务不可用')).toBeInTheDocument()
+ expect(screen.getByText('检查向量模型连接后重试')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: '取消任务' }))
+ expect(onCancelTask).toHaveBeenCalledWith('parent-task')
+ fireEvent.click(screen.getByRole('button', { name: '重试任务' }))
+ expect(onRetryTask).toHaveBeenCalledWith('child-task')
+
+ fireEvent.click(screen.getByRole('button', { name: '失败' }))
+ expect(screen.getByText('整库重建')).toBeInTheDocument()
+ expect(screen.queryByText('旧文档.md')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '历史' }))
+ expect(screen.getByText('旧文档.md')).toBeInTheDocument()
+ expect(screen.getByText('已取消')).toBeInTheDocument()
+ expect(screen.queryByText('整库重建')).not.toBeInTheDocument()
+ })
+
+ it('keeps task context and disables repeated actions while cancellation is pending', async () => {
+ let resolveCancel: (() => void) | undefined
+ const onCancelTask = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveCancel = resolve
+ })
+ )
+ render(
+
+ )
+
+ fireEvent.click(
+ screen.getAllByRole('button', { name: '查看任务' })[0]!
+ )
+ fireEvent.click(screen.getByRole('button', { name: '进行中' }))
+ const disclosure = screen.getByRole('button', {
+ name: '收起 来源同步 的阶段任务'
+ })
+ expect(disclosure).toHaveAttribute('aria-expanded', 'true')
+ expect(
+ screen.getByText('正在显示当前来源或文档的相关任务')
+ ).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: '取消任务' }))
+ expect(onCancelTask).toHaveBeenCalledOnce()
+ const pendingButton = screen.getByRole('button', {
+ name: '正在取消…'
+ })
+ expect(pendingButton).toBeDisabled()
+ fireEvent.click(pendingButton)
+ expect(onCancelTask).toHaveBeenCalledOnce()
+ expect(
+ screen.getByRole('button', { name: '进行中' })
+ ).toHaveAttribute('aria-pressed', 'true')
+ expect(disclosure).toHaveAttribute('aria-expanded', 'true')
+
+ await act(async () => {
+ resolveCancel?.()
+ })
+ expect(
+ screen.getByRole('button', { name: '取消任务' })
+ ).toBeEnabled()
+ })
+
+ it('shows a recoverable local alert when retrying a task fails', async () => {
+ const onRetryTask = vi.fn(async () => {
+ throw new Error('服务暂时不可用')
+ })
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: '任务中心' }))
+ fireEvent.click(screen.getByRole('button', { name: '失败' }))
+ const progress = screen.getByRole('progressbar', {
+ name: '失败文档.md 文档处理进度'
+ })
+ expect(progress.closest('[aria-live]')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: '重试任务' }))
+
+ const alert = await screen.findByRole('alert')
+ expect(within(alert).getByText('重试任务失败')).toBeInTheDocument()
+ expect(within(alert).getByText('服务暂时不可用')).toBeInTheDocument()
+ expect(
+ within(alert).getByText(
+ '任务和筛选已保留,请检查问题后再次操作。'
+ )
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: '失败' })
+ ).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getByText('失败文档.md')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '重试任务' })).toBeEnabled()
+ })
+
+ it('keeps legacy orphan tasks as top-level task center entries', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: '任务中心' }))
+ expect(screen.getByText('旧版导入任务')).toBeInTheDocument()
+ expect(screen.getByText('文档解析')).toBeInTheDocument()
+ expect(screen.getByText('已中断')).toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: /展开旧版导入任务/u })
+ ).not.toBeInTheDocument()
+ })
+
+ it('deduplicates document progress and merges processing status', () => {
+ render(
+
+ )
+
+ expect(
+ screen.getByRole('columnheader', { name: '处理状态' })
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('columnheader', { name: '索引进度' })
+ ).not.toBeInTheDocument()
+ expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
+ expect(screen.getByText('同步中 · 35%')).toBeInTheDocument()
+ expect(screen.getByText('生成向量 · 60%')).toBeInTheDocument()
+ fireEvent.click(
+ screen.getAllByRole('button', { name: '查看任务' })[0]!
+ )
+ expect(
+ screen.getByRole('tab', { name: '任务中心' })
+ ).toHaveAttribute('aria-selected', 'true')
+ expect(
+ screen.getByText('正在显示当前来源或文档的相关任务')
+ ).toBeInTheDocument()
+ })
+
it('edits library metadata from the detail header', async () => {
const onUpdateLibrary = vi.fn()
render()
@@ -354,21 +1218,21 @@ describe('KnowledgeWorkspace', () => {
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
expect(
- screen.getByRole('option', { name: 'GoodBuddy · 产品' })
+ screen.getByRole('option', { name: 'GoodBuddy · 概念 (CONCEPT)' })
).toBeInTheDocument()
expect(
- screen.getByRole('option', { name: 'Electron · 技术' })
+ screen.getByRole('option', { name: 'Electron · 概念 (CONCEPT)' })
).toBeInTheDocument()
expect(screen.getByText('可见关系')).toBeInTheDocument()
- expect(screen.getByText('使用')).toBeInTheDocument()
+ expect(screen.getByText('使用 (USES)')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('搜索图谱实体'), {
target: { value: 'Electron' }
})
expect(
- screen.queryByRole('option', { name: 'GoodBuddy · 产品' })
+ screen.queryByRole('option', { name: 'GoodBuddy · 概念 (CONCEPT)' })
).not.toBeInTheDocument()
- expect(screen.queryByText('使用')).not.toBeInTheDocument()
+ expect(screen.queryByText('使用 (USES)')).not.toBeInTheDocument()
expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
data: {
@@ -386,14 +1250,14 @@ describe('KnowledgeWorkspace', () => {
target: { value: '' }
})
fireEvent.change(screen.getByLabelText('筛选实体类型'), {
- target: { value: '产品' }
+ target: { value: 'CONCEPT' }
})
expect(
- screen.getByRole('option', { name: 'GoodBuddy · 产品' })
+ screen.getByRole('option', { name: 'GoodBuddy · 概念 (CONCEPT)' })
).toBeInTheDocument()
expect(
- screen.queryByRole('option', { name: 'Electron · 技术' })
- ).not.toBeInTheDocument()
+ screen.getByRole('option', { name: 'Electron · 概念 (CONCEPT)' })
+ ).toBeInTheDocument()
})
it('provides responsive workspace and graph layout hooks', () => {
@@ -516,7 +1380,10 @@ describe('KnowledgeWorkspace', () => {
behaviors: expect.arrayContaining([
'drag-canvas',
'zoom-canvas',
- 'drag-element',
+ expect.objectContaining({
+ type: 'drag-element-force',
+ fixed: true
+ }),
expect.objectContaining({ type: 'auto-adapt-label' })
])
})
@@ -561,6 +1428,14 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByText('115%')).toBeInTheDocument()
expect(g6Mock.graph.zoomTo).toHaveBeenLastCalledWith(1.15, false)
+ const fitViewCallCount = g6Mock.graph.fitView.mock.calls.length
+ fireEvent.click(screen.getByRole('button', { name: '显示全部' }))
+ await waitFor(() =>
+ expect(g6Mock.graph.fitView).toHaveBeenCalledTimes(
+ fitViewCallCount + 1
+ )
+ )
+
act(() => {
g6Mock.handlers.get('node:click')?.({
target: { id: 'entity-1' },
@@ -750,7 +1625,7 @@ describe('KnowledgeWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: '新增' }))
fireEvent.change(screen.getByLabelText('关系类型'), {
- target: { value: '依赖' }
+ target: { value: 'DEPENDS_ON' }
})
fireEvent.change(screen.getByLabelText('说明'), {
target: { value: '桌面运行基础' }
@@ -760,7 +1635,7 @@ describe('KnowledgeWorkspace', () => {
expect(onCreateRelation).toHaveBeenCalledWith({
sourceId: 'entity-1',
targetId: 'entity-2',
- type: '依赖',
+ type: 'DEPENDS_ON',
description: '桌面运行基础'
})
)
diff --git a/src/renderer/src/KnowledgeWorkspace.tsx b/src/renderer/src/KnowledgeWorkspace.tsx
index 561360d..1ec7446 100644
--- a/src/renderer/src/KnowledgeWorkspace.tsx
+++ b/src/renderer/src/KnowledgeWorkspace.tsx
@@ -4,6 +4,8 @@ import {
ArrowRight,
BookOpen,
Check,
+ ChevronDown,
+ ChevronRight,
CirclePause,
Database,
FilePlus2,
@@ -27,6 +29,7 @@ import {
ZoomOut
} from 'lucide-react'
import {
+ useCallback,
useEffect,
useMemo,
useRef,
@@ -34,47 +37,64 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
+import type {
+ KnowledgeDocumentItem as SharedKnowledgeDocumentItem,
+ KnowledgeEvidence as SharedKnowledgeEvidence,
+ KnowledgeGraphNode as SharedKnowledgeGraphNode,
+ KnowledgeGraphRelation as SharedKnowledgeGraphRelation,
+ KnowledgeLibrary as SharedKnowledgeLibrary,
+ KnowledgeSourceItem as SharedKnowledgeSource,
+ KnowledgeTaskItem as SharedKnowledgeTaskItem
+} from '../../shared/contracts'
+import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
+import type {
+ KnowledgeEmbeddingIndexSnapshot
+} from '../../shared/embedding-contracts'
+import {
+ defaultKnowledgeChunkingSettings,
+ defaultKnowledgeRetrievalSettings,
+ type KnowledgeChunkPage,
+ type KnowledgeChunkUpdateInput,
+ type KnowledgeChunkingSettings,
+ type KnowledgeRetrievalResponse,
+ type KnowledgeRetrievalSettings
+} from '../../shared/knowledge-contracts'
+import {
+ defaultKnowledgeOntologySettings,
+ getKnowledgeOntologyDisplayDefinitions,
+ isRelationEndpointAllowed,
+ knowledgeOntologySettingsSchema,
+ normalizeEntityTypeAlias,
+ normalizeRelationTypeAlias,
+ type KnowledgeOntologySettings
+} from '../../shared/knowledge-ontology'
import {
EmptyState,
PageHeader,
PageTabs,
+ SegmentedControl,
type PageTab
} from './WorkspacePrimitives'
import { KnowledgeGraphChart } from './KnowledgeGraphChart'
+import {
+ KnowledgeChunkManager
+} from './KnowledgeChunkManager'
+import {
+ KnowledgeRetrievalWorkbench,
+ type KnowledgeRetrievalWorkbenchResponse,
+ type KnowledgeRetrievalWorkbenchSettings
+} from './KnowledgeRetrievalWorkbench'
import { trapTabFocus } from './dialog-focus'
+import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection'
-export type KnowledgeStorageMode = 'reference' | 'managed'
-export type KnowledgeGraphStrategy =
- | 'rules'
- | 'model'
- | 'hybrid'
- | 'ask'
-export type KnowledgeSourceKind = 'file' | 'directory' | 'url'
-export type KnowledgeSourceStatus =
- | 'queued'
- | 'syncing'
- | 'paused'
- | 'ready'
- | 'failed'
-export type KnowledgeDocumentStatus =
- | 'queued'
- | 'parsing'
- | 'indexing'
- | 'ready'
- | 'failed'
-
-export type KnowledgeLibrary = {
- id: string
- name: string
- description?: string
- storageMode: KnowledgeStorageMode
- graphEnabled: boolean
- graphStrategy: KnowledgeGraphStrategy
- sourceCount: number
- documentCount: number
- indexedDocumentCount: number
- updatedAt?: string
-}
+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
@@ -84,75 +104,10 @@ export type CreateKnowledgeLibraryInput = {
graphStrategy: KnowledgeGraphStrategy
}
-export type KnowledgeSource = {
- id: string
- libraryId: string
- name: string
- kind: KnowledgeSourceKind
- location?: string
- status: KnowledgeSourceStatus
- progress?: number
- documentCount: number
- lastSyncedAt?: string
- error?: string
-}
-
-export type KnowledgeDocumentItem = {
- id: string
- libraryId: string
- sourceId?: string
- name: string
- path?: string
- status: KnowledgeDocumentStatus
- indexProgress?: number
- chunkCount?: number
- size?: number
- updatedAt?: string
- error?: string
-}
-
-export type KnowledgeGraphNode = {
- id: string
- label: string
- type: string
- description?: string
- aliases?: readonly string[]
- x: number
- y: number
- evidenceIds?: readonly string[]
-}
-
-export type KnowledgeGraphRelation = {
- id: string
- sourceId: string
- targetId: string
- type: string
- description?: string
- evidenceIds?: readonly string[]
-}
-
-export type KnowledgeEvidence = {
- id: string
- documentId: string
- documentName: string
- excerpt: string
- location?: string
-}
-
-export type KnowledgeTaskItem = {
- id: string
- libraryId: string
- sourceId?: string
- documentId?: string
- documentName: string
- kind: 'parsing' | 'embedding' | 'graph'
- status: 'queued' | 'running' | 'succeeded' | 'failed' | 'skipped'
- progress: number
- message?: string
- createdAt: string
- startedAt?: string
- completedAt?: string
-}
+export type KnowledgeGraphNode = SharedKnowledgeGraphNode
+export type KnowledgeGraphRelation = SharedKnowledgeGraphRelation
+export type KnowledgeEvidence = SharedKnowledgeEvidence
+export type KnowledgeTaskItem = SharedKnowledgeTaskItem
export type KnowledgeEntityUpdate = {
label: string
@@ -214,6 +169,55 @@ export type KnowledgeWorkspaceProps = {
onPauseSource: (sourceId: string) => void | Promise
onRetrySource: (sourceId: string) => void | Promise
onRemoveSource: (sourceId: string) => void | Promise
+ onRetrieve: (
+ libraryId: string,
+ query: string,
+ settings: KnowledgeRetrievalSettings
+ ) => Promise
+ onUpdateKnowledgeSettings: (
+ libraryId: string,
+ settings: {
+ retrieval?: KnowledgeRetrievalSettings
+ chunking?: KnowledgeChunkingSettings
+ ontology?: KnowledgeOntologySettings
+ }
+ ) => void | Promise
+ onListChunks: (input: {
+ libraryId: string
+ documentId: string
+ page: number
+ pageSize: number
+ search?: string
+ }) => Promise
+ onUpdateChunk: (
+ input: KnowledgeChunkUpdateInput
+ ) => void | Promise
+ onDeleteChunk: (input: {
+ knowledgeBaseId: string
+ documentId: string
+ chunkId: string
+ }) => void | Promise
+ onRebuildDocument: (
+ libraryId: string,
+ documentId: string
+ ) => void | Promise
+ onRebuildLibrary: (libraryId: string) => void | Promise
+ onCancelRebuild: (libraryId: string) => void | Promise
+ onGetEmbeddingIndex: (
+ libraryId: string
+ ) =>
+ | KnowledgeEmbeddingIndexSnapshot
+ | Promise
+ onRebuildEmbeddingIndex: (
+ libraryId: string
+ ) => Promise
+ onCancelTask: (taskId: string) => void | Promise
+ onRetryTask: (taskId: string) => void | Promise
+ onOpenReferenceSource: (input: {
+ knowledgeBaseId: string
+ documentId: string
+ chunkId: string
+ }) => void | Promise
onMoveNode: (
nodeId: string,
position: { x: number; y: number }
@@ -242,6 +246,7 @@ export type KnowledgeWorkspaceProps = {
}
type WorkspaceTab = 'documents' | 'graph' | 'tasks' | 'settings'
+type GraphWorkspaceTab = 'explore' | 'settings'
type GraphSidebarTab = 'topology' | 'details'
const storageModeLabelKeys = {
@@ -256,6 +261,14 @@ const strategyLabelKeys = {
ask: 'strategies.ask'
} as const satisfies Record
+function parseAliases(value: string, limit?: number): string[] {
+ const aliases = value
+ .split(/[、,,]/)
+ .map((item) => item.trim())
+ .filter(Boolean)
+ return limit === undefined ? aliases : aliases.slice(0, limit)
+}
+
const sourceStatusLabelKeys = {
queued: 'sourceStatuses.queued',
syncing: 'sourceStatuses.syncing',
@@ -273,19 +286,121 @@ const documentStatusLabelKeys = {
} as const satisfies Record
const taskKindLabelKeys = {
+ 'source-sync': 'taskKinds.sourceSync',
+ 'document-process': 'taskKinds.documentProcess',
+ 'document-rebuild': 'taskKinds.documentRebuild',
+ 'library-rebuild': 'taskKinds.libraryRebuild',
+ 'embedding-rebuild': 'taskKinds.embeddingRebuild',
+ 'graph-rebuild': 'taskKinds.graphRebuild',
parsing: 'taskKinds.parsing',
embedding: 'taskKinds.embedding',
graph: 'taskKinds.graph'
} as const satisfies Record
+const taskStageLabelKeys = {
+ queued: 'taskStages.queued',
+ syncing: 'taskStages.syncing',
+ reading: 'taskStages.reading',
+ parsing: 'taskStages.parsing',
+ chunking: 'taskStages.chunking',
+ indexing: 'taskStages.indexing',
+ embedding: 'taskStages.embedding',
+ graph: 'taskStages.graph',
+ finalizing: 'taskStages.finalizing'
+} as const satisfies Record
+
const taskStatusLabelKeys = {
queued: 'taskStatuses.queued',
running: 'taskStatuses.running',
succeeded: 'taskStatuses.succeeded',
failed: 'taskStatuses.failed',
- skipped: 'taskStatuses.skipped'
+ cancelled: 'taskStatuses.cancelled',
+ skipped: 'taskStatuses.skipped',
+ interrupted: 'taskStatuses.interrupted'
} as const satisfies Record
+const taskScopeLabelKeys = {
+ library: 'taskScopes.library',
+ source: 'taskScopes.source',
+ document: 'taskScopes.document'
+} as const satisfies Record
+
+function toWorkbenchResponse(
+ response: KnowledgeRetrievalResponse,
+ libraryDocumentCount: number
+): KnowledgeRetrievalWorkbenchResponse {
+ const contextByChunkId = new Map(
+ response.context.groups.map((group) => [
+ group.resultChunkId,
+ group
+ ])
+ )
+ return {
+ diagnostics: {
+ durationMs: response.durationMs,
+ requestedChannels: response.diagnostics.requestedChannels,
+ usedChannels: response.diagnostics.usedChannels,
+ degradedChannels: response.diagnostics.degradedChannels,
+ candidateCounts: response.diagnostics.candidateCounts,
+ channelDurationsMs: response.diagnostics.channelDurationMs,
+ vectorScannedCount: response.diagnostics.vectorScannedCount,
+ rerank: response.diagnostics.rerank
+ },
+ results: response.results.map((result) => {
+ const context = contextByChunkId.get(result.chunkId)
+ return {
+ chunkId: result.chunkId,
+ documentId: result.documentId,
+ rank: result.rank,
+ documentName: result.documentTitle,
+ sourceName: result.sourceDisplayName,
+ locator: result.location,
+ snippet: stripKnowledgeHighlightTags(result.snippet),
+ fusedScore: result.scores.fusedScore,
+ relevance: result.relevance,
+ channels: result.channels,
+ channelDetails: {
+ fts: {
+ rank: result.scores.ftsRank
+ },
+ cjk: {
+ rank: result.scores.cjkRank
+ },
+ vector: {
+ rank: result.scores.vectorRank,
+ similarity: result.scores.vectorSimilarity
+ },
+ graph: {
+ rank: result.scores.graphRank
+ }
+ },
+ rankBeforeRerank: result.preRerankRank,
+ contextText: context?.content,
+ contextCharacterCount: context?.characterCount,
+ contextTruncated: context?.truncated
+ }
+ }),
+ context: {
+ characterCount: response.context.characterCount,
+ budget: response.settings.contextMaxCharacters,
+ truncated: response.context.truncated
+ },
+ zeroReason:
+ response.results.length > 0
+ ? undefined
+ : libraryDocumentCount === 0
+ ? 'empty-library'
+ : response.diagnostics.filteredByThresholdCount > 0
+ ? 'filtered'
+ : response.diagnostics.degradedChannels.some(
+ (item) => item.channel === 'vector'
+ ) &&
+ response.diagnostics.usedChannels.length === 0
+ ? 'index-unavailable'
+ : 'no-match'
+ }
+}
+
function resolvedLocale(language: string): string {
return language || 'zh-CN'
}
@@ -462,45 +577,6 @@ function toErrorMessage(
: t('errors.operationFailed')
}
-function ProgressBar({
- label,
- progress
-}: {
- label: string
- progress: number | undefined
-}): React.JSX.Element {
- const { i18n } = useTranslation('knowledge')
- const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
- const value = clampProgress(progress)
- const formattedProgress = formatPercent(value / 100, locale)
- return (
-
-
-
- )
-}
-
function CreateLibraryWizard({
onCancel,
onCreate
@@ -513,7 +589,7 @@ function CreateLibraryWizard({
const [description, setDescription] = useState('')
const [storageMode, setStorageMode] =
useState('reference')
- const [graphEnabled, setGraphEnabled] = useState(true)
+ const [graphEnabled, setGraphEnabled] = useState(false)
const [graphStrategy, setGraphStrategy] =
useState('rules')
const [saving, setSaving] = useState(false)
@@ -979,10 +1055,13 @@ function DocumentsView({
onImportFiles,
onImportUrl,
onPauseSource,
+ onManageChunks,
onRemoveSource,
onRetrySource,
onSyncSource,
- sources
+ onViewTasks,
+ sources,
+ tasks
}: Pick<
KnowledgeWorkspaceProps,
| 'documents'
@@ -994,8 +1073,14 @@ function DocumentsView({
| '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)
@@ -1060,7 +1145,11 @@ function DocumentsView({
{t('documents.sources.title')}
- {t('documents.sources.description')}
+ {t(
+ library.graphEnabled
+ ? 'documents.sources.descriptionWithGraph'
+ : 'documents.sources.description'
+ )}
@@ -1281,7 +1370,11 @@ function DocumentsView({
listStyle: 'none'
}}
>
- {sources.map((source) => (
+ {sources.map((source) => {
+ const relatedTasks = tasks?.filter(
+ (task) => task.sourceId === source.id
+ ) ?? []
+ return (
{t(sourceStatusLabelKeys[source.status])}
+ {source.status === 'syncing' &&
+ source.progress !== undefined
+ ? ` · ${formatPercent(
+ clampProgress(source.progress) / 100,
+ locale
+ )}`
+ : ''}
@@ -1346,16 +1446,6 @@ function DocumentsView({
time: formatTime(source.lastSyncedAt, locale, t)
})}
- {source.status === 'syncing' && (
-
- )}
{source.error && (
+ {relatedTasks.length > 0 && (
+
+ )}
{source.status === 'syncing' ? (
- ))}
+ )
+ })}
)}
@@ -1504,10 +1607,7 @@ function DocumentsView({
{t('documents.table.columns.document')}
- {t('documents.table.columns.status')}
- |
-
- {t('documents.table.columns.indexProgress')}
+ {t('documents.table.columns.processingStatus')}
|
{t('documents.table.columns.chunks')}
@@ -1515,10 +1615,22 @@ function DocumentsView({
|
{t('documents.table.columns.size')}
|
+
+ {t('documents.table.columns.actions')}
+ |
- {filteredDocuments.map((document) => (
+ {filteredDocuments.map((document) => {
+ const relatedTasks = tasks?.filter(
+ (task) => task.documentId === document.id
+ ) ?? []
+ const activeTask = relatedTasks.find(
+ (task) =>
+ task.status === 'queued' ||
+ task.status === 'running'
+ )
+ return (
|
-
- {t(documentStatusLabelKeys[document.status])}
-
+
+
+ {t(documentStatusLabelKeys[document.status])}
+
+ {activeTask && (
+
+ {t(taskStageLabelKeys[activeTask.stage])}
+ {' · '}
+ {formatPercent(
+ clampProgress(activeTask.progress) / 100,
+ locale
+ )}
+
+ )}
+
{document.error && (
)}
|
-
-
- |
{document.chunkCount === undefined
? '—'
@@ -1584,8 +1687,33 @@ function DocumentsView({
|
{formatSize(document.size, locale, t)}
|
+
+
+ {relatedTasks.length > 0 && (
+
+ )}
+
+
+ |
- ))}
+ )
+ })}
@@ -1597,16 +1725,30 @@ function DocumentsView({
function EntityEditor({
node,
+ ontology,
onCancel,
onSave
}: {
node?: KnowledgeGraphNode
+ ontology: KnowledgeOntologySettings
onCancel: () => void
onSave: (update: KnowledgeEntityUpdate) => void | Promise
}): React.JSX.Element {
- const { t } = useTranslation('knowledge')
+ 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(node?.type ?? '')
+ const [type, setType] = useState(
+ normalizeEntityTypeAlias(node?.type, ontology)
+ )
const [description, setDescription] = useState(node?.description ?? '')
const [aliases, setAliases] = useState(
(node?.aliases ?? []).join(t('format.listSeparator'))
@@ -1623,10 +1765,7 @@ function EntityEditor({
label: label.trim(),
type: type.trim(),
description: description.trim(),
- aliases: aliases
- .split(/[、,,]/)
- .map((item) => item.trim())
- .filter(Boolean)
+ aliases: parseAliases(aliases)
})
}}
style={{ display: 'grid', gap: 10 }}
@@ -1642,12 +1781,18 @@ function EntityEditor({