fix: make local knowledge bases ready for daily use

Knowledge selection was shared globally, document readiness overstated usable content, and important import and source-management paths exposed technical details or lacked safe recovery actions. Knowledge scope now persists per conversation, readiness reflects real processing channels, and the workspace guides users from creation and import into cited chat with controlled source actions.

Release note: 知识库现按对话保存使用范围,准确区分可用、处理中和失败文档,并提供更简洁的创建、检索测试、来源打开、删除确认和失败重试流程。
This commit is contained in:
mesalogo
2026-09-05 15:51:07 +08:00
parent d5fa0ec67c
commit dd8289fdef
29 changed files with 1395 additions and 87 deletions
@@ -3954,6 +3954,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
],
knowledgeRetrievalMode: 'always',
title: '发布讨论',
updatedAt: 1_775_000_000_000,
@@ -4055,6 +4058,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
],
knowledgeRetrievalMode: 'always',
messages: [
expect.objectContaining({
@@ -4442,6 +4448,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: channelDefaultProfileId
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
],
knowledgeRetrievalMode: 'always',
contextMetrics: {
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
@@ -4550,6 +4559,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: channelDefaultProfileId
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
],
knowledgeRetrievalMode: 'always',
branch: {
sourceConversationId,
@@ -4623,6 +4635,7 @@ describe('AssistantDatabase', () => {
id: branch.id,
projectId: branch.projectId,
runtimeSelection: branch.runtimeSelection,
knowledgeLibraryIds: branch.knowledgeLibraryIds,
knowledgeRetrievalMode: branch.knowledgeRetrievalMode,
branch: branch.branch,
title: '发布方案讨论 · 分支(独立更新)',
+15 -6
View File
@@ -1033,6 +1033,7 @@ function interruptActiveToolBlocks(
}
const conversationContextStateSchema = conversationSnapshotSchema.pick({
knowledgeLibraryIds: true,
contextMetrics: true,
contextCompressionState: true
})
@@ -1041,7 +1042,7 @@ function parseConversationContextState(
value: string | null
): Pick<
ConversationSnapshot,
'contextMetrics' | 'contextCompressionState'
'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
> {
if (!value) {
return {}
@@ -1059,12 +1060,14 @@ function parseConversationContextState(
function serializeConversationContextState(
conversation: Pick<
ConversationSnapshot,
'contextMetrics' | 'contextCompressionState'
'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
>
): string | null {
return conversation.contextMetrics ||
return conversation.knowledgeLibraryIds !== undefined ||
conversation.contextMetrics ||
conversation.contextCompressionState
? JSON.stringify({
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState
})
@@ -1081,9 +1084,9 @@ function toConversationSnapshot(
runtimeSelection: parseRuntimeSelection(
conversation.runtime_selection_json
),
...parseConversationContextState(conversation.context_state_json),
knowledgeRetrievalMode:
conversation.knowledge_retrieval_mode ?? undefined,
...parseConversationContextState(conversation.context_state_json),
...(conversation.channel &&
conversation.conversation_type &&
conversation.account_display
@@ -2992,7 +2995,7 @@ export class AssistantDatabase {
const source = database
.prepare(
`SELECT id, project_id, runtime_selection_json,
knowledge_retrieval_mode, title
knowledge_retrieval_mode, context_state_json, title
FROM conversations
WHERE id = ? AND status = 'active' AND channel IS NULL`
)
@@ -3002,6 +3005,7 @@ export class AssistantDatabase {
project_id: string | null
runtime_selection_json: string | null
knowledge_retrieval_mode: 'auto' | 'always' | null
context_state_json: string | null
title: string
}
| undefined
@@ -3052,13 +3056,18 @@ export class AssistantDatabase {
knowledge_retrieval_mode, context_state_json, work_mode,
title, branch_source_conversation_id, branch_source_title,
status, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, 'ask', ?, ?, ?, 'active', ?, ?)`
VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)`
)
.run(
destinationConversationId,
source.project_id,
source.runtime_selection_json,
source.knowledge_retrieval_mode,
serializeConversationContextState({
knowledgeLibraryIds:
parseConversationContextState(source.context_state_json)
.knowledgeLibraryIds
}),
input.title,
source.id,
source.title,
+42
View File
@@ -74,6 +74,7 @@ import {
knowledgeChunkPageSchema,
knowledgeChunksListInputSchema,
knowledgeChunkUpdateInputSchema,
knowledgeDocumentOpenInputSchema,
knowledgeDocumentRebuildInputSchema,
knowledgeLibraryRebuildInputSchema,
knowledgeReferenceContextInputSchema,
@@ -892,6 +893,8 @@ function getKnowledgeSnapshot(
sourceCount: library.sourceCount,
documentCount: library.documentCount,
indexedDocumentCount: library.indexedDocumentCount,
processingDocumentCount: library.processingDocumentCount,
failedDocumentCount: library.failedDocumentCount,
retrievalSettings: library.retrievalSettings,
chunkingSettings: library.chunkingSettings,
chunkingRebuildRequired: library.chunkingRebuildRequired,
@@ -926,6 +929,9 @@ function getKnowledgeSnapshot(
name: document.title,
path: document.sourceLocation,
status: document.status,
textIndexStatus: document.textIndexStatus,
vectorIndexStatus: document.vectorIndexStatus,
graphIndexStatus: document.graphIndexStatus,
indexProgress: document.status === 'ready' ? 100 : 0,
chunkCount: document.chunkCount,
size: document.size,
@@ -7969,6 +7975,42 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.knowledgeOpenDocumentSource,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeDocumentOpenInputSchema.parse(input)
const reference = knowledgeService.getDocumentSource(value)
if (!reference) {
throw new Error('文档来源不存在')
}
if (reference.source.type === 'url') {
const target = new URL(reference.source.location)
if (!['http:', 'https:'].includes(target.protocol)) {
throw new Error('文档来源 URL 协议不受支持')
}
await shell.openExternal(target.href)
return
}
const storedPath =
reference.document.sourceLocation ?? reference.source.location
if (!isAbsolute(storedPath)) {
throw new Error('文档来源路径无效')
}
if ((await lstat(storedPath)).isSymbolicLink()) {
throw new Error('文档来源不能是符号链接')
}
const targetPath = await realpath(storedPath)
if (!(await stat(targetPath)).isFile()) {
throw new Error('文档来源不是可打开的文件')
}
const openError = await shell.openPath(targetPath)
if (openError) {
throw new Error('无法打开文档来源')
}
}
)
registerHandler(
ipcChannels.knowledgeReferenceContext,
(event, input: unknown) => {
@@ -207,6 +207,51 @@ describe('KnowledgeDatabase', () => {
).toBe(true)
})
it('counts only ready documents as indexed and separates actionable states', async () => {
const { database } = await createDatabase()
const library = database.createKnowledgeBase({
name: 'Status counts',
storageMode: 'reference'
})
const source = database.upsertSource({
knowledgeBaseId: library.id,
type: 'file',
location: 'C:\\notes\\status.md',
displayName: 'status.md',
status: 'ready'
})
for (const [index, status] of [
'ready',
'queued',
'parsing',
'indexing',
'failed'
].entries()) {
database.upsertDocument(
{
knowledgeBaseId: library.id,
sourceId: source.id,
externalId: `status-${index}`,
title: `Status ${index}`,
metadata: { status }
},
[{
id: `status-${index}-chunk`,
ordinal: 0,
content: `status ${status}`
}]
)
}
expect(database.getKnowledgeBaseCounts().get(library.id)).toEqual({
sourceCount: 1,
documentCount: 5,
indexedDocumentCount: 1,
processingDocumentCount: 3,
failedDocumentCount: 1
})
})
it('normalizes legacy negative vector thresholds without losing settings', async () => {
const { database, path } = await createDatabase()
const library = database.createKnowledgeBase({
+18 -1
View File
@@ -846,6 +846,8 @@ export class KnowledgeDatabase {
sourceCount: number
documentCount: number
indexedDocumentCount: number
processingDocumentCount: number
failedDocumentCount: number
}
> {
const rows = this.requireDatabase()
@@ -855,8 +857,15 @@ export class KnowledgeDatabase {
COUNT(DISTINCT d.id) AS document_count,
COUNT(DISTINCT CASE
WHEN COALESCE(json_extract(d.metadata, '$.status'), 'ready')
<> 'failed'
= 'ready'
THEN d.id END) AS indexed_document_count
,COUNT(DISTINCT CASE
WHEN json_extract(d.metadata, '$.status')
IN ('queued', 'parsing', 'indexing')
THEN d.id END) AS processing_document_count
,COUNT(DISTINCT CASE
WHEN json_extract(d.metadata, '$.status') = 'failed'
THEN d.id END) AS failed_document_count
FROM knowledge_bases kb
LEFT JOIN knowledge_sources s ON s.knowledge_base_id = kb.id
LEFT JOIN documents d ON d.knowledge_base_id = kb.id
@@ -872,6 +881,14 @@ export class KnowledgeDatabase {
indexedDocumentCount: asNumber(
row,
'indexed_document_count'
),
processingDocumentCount: asNumber(
row,
'processing_document_count'
),
failedDocumentCount: asNumber(
row,
'failed_document_count'
)
}
])
@@ -7,6 +7,7 @@ import {
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { strToU8, zipSync } from 'fflate'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ExtractStructured } from './graph-extractor'
import { embeddingStorageProvider } from './embedding-provider-key'
@@ -17,6 +18,83 @@ import { UrlImporter } from './url-importer'
const temporaryDirectories: string[] = []
const services: KnowledgeService[] = []
function createRealDocxFixture(): Buffer {
return Buffer.from(
zipSync({
'[Content_Types].xml': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
'</Types>'
),
'_rels/.rels': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
'</Relationships>'
),
'word/document.xml': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
'<w:body>' +
'<w:p><w:r><w:t>蓝鲸发布流程</w:t></w:r></w:p>' +
'<w:p><w:r><w:t>发布前必须完成审批和回归测试。</w:t></w:r></w:p>' +
'<w:tbl><w:tr><w:tc><w:p><w:r><w:t>负责人</w:t></w:r></w:p></w:tc>' +
'<w:tc><w:p><w:r><w:t>产品团队</w:t></w:r></w:p></w:tc></w:tr></w:tbl>' +
'<w:sectPr/></w:body></w:document>'
),
'word/_rels/document.xml.rels': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
)
})
)
}
function createRealPdfFixture(...pageTexts: string[]): Buffer {
const texts = pageTexts.length > 0 ? pageTexts : ['']
const fontObjectId = texts.length + 3
const firstContentObjectId = fontObjectId + 1
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
`<< /Type /Pages /Kids [${texts
.map((_, index) => `${index + 3} 0 R`)
.join(' ')}] /Count ${texts.length} >>`,
...texts.map(
(_, index) =>
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' +
`/Resources << /Font << /F1 ${fontObjectId} 0 R >> >> ` +
`/Contents ${firstContentObjectId + index} 0 R >>`
),
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
...texts.map((text) => {
const stream = `BT /F1 14 Tf 72 720 Td (${text}) Tj ET`
return (
`<< /Length ${Buffer.byteLength(stream)} >>\n` +
`stream\n${stream}\nendstream`
)
})
]
let content = '%PDF-1.4\n'
const offsets = [0]
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(content))
content += `${index + 1} 0 obj\n${object}\nendobj\n`
}
const xrefOffset = Buffer.byteLength(content)
content += `xref\n0 ${objects.length + 1}\n`
content += '0000000000 65535 f \n'
content += offsets
.slice(1)
.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`)
.join('')
content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`
content += `startxref\n${xrefOffset}\n%%EOF\n`
return Buffer.from(content)
}
async function createService(
urlImporter?: UrlImporter,
embeddingProvider?: EmbeddingProvider,
@@ -48,6 +126,83 @@ afterEach(async () => {
})
describe('KnowledgeService', () => {
it('imports real DOCX and PDF files from disk into searchable chunks', async () => {
const { directory, service } = await createService()
const docxPath = join(directory, '蓝鲸发布手册.docx')
const pdfPath = join(directory, 'Blue-Harbor-operations.pdf')
await Promise.all([
writeFile(docxPath, createRealDocxFixture()),
writeFile(
pdfPath,
createRealPdfFixture(
'Blue Harbor deployment requires a signed approval.',
'Rollback begins by restoring the verified package.'
)
)
])
const library = service.createLibrary({
name: '真实办公文档',
storageMode: 'reference',
graphEnabled: false
})
await service.importPaths(library.id, [docxPath, pdfPath])
const snapshot = service.snapshot(library.id)
expect(snapshot.libraries[0]).toMatchObject({
documentCount: 2,
indexedDocumentCount: 2,
processingDocumentCount: 0,
failedDocumentCount: 0
})
expect(snapshot.documents).toHaveLength(2)
expect(
snapshot.documents.every(
(document) =>
document.status === 'ready' &&
document.textIndexStatus === 'ready' &&
document.vectorIndexStatus === 'disabled'
)
).toBe(true)
const docxDocument = snapshot.documents.find(
(document) => document.sourceLocation === docxPath
)
const pdfDocument = snapshot.documents.find(
(document) => document.sourceLocation === pdfPath
)
expect(docxDocument).toBeDefined()
expect(pdfDocument).toBeDefined()
expect(
service.getDocumentSource({
knowledgeBaseId: library.id,
documentId: docxDocument!.id
})
).toMatchObject({
document: { sourceLocation: docxPath },
source: { knowledgeBaseId: library.id }
})
const docxChunks = service.database.listChunks(docxDocument!.id)
const pdfChunks = service.database.listChunks(pdfDocument!.id)
expect(docxChunks.map((chunk) => chunk.content).join('\n')).toContain(
'发布前必须完成审批和回归测试'
)
expect(docxChunks.some((chunk) => chunk.location?.includes('表格'))).toBe(
true
)
expect(pdfChunks.map((chunk) => chunk.location)).toEqual([
'第 1 页',
'第 2 页'
])
expect(service.search(library.id, '发布审批')[0]?.document.id).toBe(
docxDocument!.id
)
expect(
service.search(library.id, 'verified package')[0]?.document.id
).toBe(pdfDocument!.id)
})
it('uses document and query embedding roles without exposing providers to runtimes', async () => {
const embed = vi.fn(async (input: readonly string[]) =>
input.map(() => [9, 9])
+88 -2
View File
@@ -30,6 +30,8 @@ import {
knowledgeDocumentRebuildInputSchema,
knowledgeLibraryRebuildInputSchema,
knowledgeReferenceContextInputSchema,
knowledgeDocumentOpenInputSchema,
type KnowledgeDocumentOpenInput,
knowledgeRetrieveInputSchema,
knowledgeSettingsUpdateInputSchema,
type KnowledgeChunkDeleteInput,
@@ -109,6 +111,8 @@ export type KnowledgeLibrarySnapshot = KnowledgeBase & {
sourceCount: number
documentCount: number
indexedDocumentCount: number
processingDocumentCount: number
failedDocumentCount: number
}
export type KnowledgeSourceSnapshot = KnowledgeSource & {
@@ -120,6 +124,9 @@ export type KnowledgeSourceSnapshot = KnowledgeSource & {
export type KnowledgeDocumentSnapshot = Document & {
chunkCount: number
status: 'queued' | 'parsing' | 'indexing' | 'ready' | 'failed'
textIndexStatus: 'waiting' | 'ready' | 'failed'
vectorIndexStatus: 'disabled' | 'waiting' | 'ready' | 'failed' | 'missing'
graphIndexStatus: 'disabled' | 'on-demand' | 'waiting' | 'ready' | 'failed'
size?: number
error?: string
}
@@ -1067,7 +1074,9 @@ export class KnowledgeService {
const counts = libraryCounts.get(library.id) ?? {
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0
indexedDocumentCount: 0,
processingDocumentCount: 0,
failedDocumentCount: 0
}
return {
...library,
@@ -1120,6 +1129,21 @@ export class KnowledgeService {
: undefined
}))
const chunkCounts = this.database.getDocumentChunkCounts(libraryId)
const tasks = this.database.listKnowledgeTasks(libraryId)
const library = libraries.find((item) => item.id === libraryId)
const graphTaskByDocument = new Map<
string,
KnowledgeTaskItem
>()
for (const task of tasks) {
if (
task.documentId &&
task.kind === 'graph' &&
!graphTaskByDocument.has(task.documentId)
) {
graphTaskByDocument.set(task.documentId, task)
}
}
const documents = libraryDocuments.map((document) => {
const status =
typeof document.metadata.status === 'string' &&
@@ -1132,6 +1156,49 @@ export class KnowledgeService {
...document,
chunkCount: chunkCounts.get(document.id) ?? 0,
status,
textIndexStatus:
status === 'ready'
? ('ready' as const)
: status === 'failed'
? ('failed' as const)
: ('waiting' as const),
vectorIndexStatus: (() => {
if (!this.embeddingProvider) {
return 'disabled' as const
}
if (status !== 'ready') {
return 'waiting' as const
}
const state = this.database.getEmbeddingIndexState(
document.id,
embeddingStorageProvider(this.embeddingProvider),
this.embeddingProvider.model
)
return state?.status === 'ready'
? ('ready' as const)
: state?.status === 'error'
? ('failed' as const)
: ('missing' as const)
})(),
graphIndexStatus: (() => {
if (!library?.graphEnabled) {
return 'disabled' as const
}
if (library.graphStrategy === 'ask') {
return 'on-demand' as const
}
if (status !== 'ready') {
return 'waiting' as const
}
const task = graphTaskByDocument.get(document.id)
if (task?.status === 'failed' || task?.status === 'interrupted') {
return 'failed' as const
}
if (task?.status === 'succeeded') {
return 'ready' as const
}
return 'waiting' as const
})(),
size:
typeof document.metadata.size === 'number'
? document.metadata.size
@@ -1150,7 +1217,7 @@ export class KnowledgeService {
entities: graph.entities,
relations: graph.relations,
evidence: graph.evidence,
tasks: this.database.listKnowledgeTasks(libraryId)
tasks
}
}
@@ -1698,6 +1765,25 @@ export class KnowledgeService {
}
}
getDocumentSource(rawInput: KnowledgeDocumentOpenInput) {
const input = knowledgeDocumentOpenInputSchema.parse(rawInput)
const document = this.database.getDocument(input.documentId)
if (
!document ||
document.knowledgeBaseId !== input.knowledgeBaseId
) {
return undefined
}
const source = this.database.getSource(document.sourceId)
if (
!source ||
source.knowledgeBaseId !== input.knowledgeBaseId
) {
return undefined
}
return { document, source }
}
async searchHybrid(
knowledgeBaseId: string,
query: string,
+7
View File
@@ -149,6 +149,7 @@ import type {
KnowledgeChunkPage,
KnowledgeChunkUpdateInput,
KnowledgeChunksListInput,
KnowledgeDocumentOpenInput,
KnowledgeDocumentRebuildInput,
KnowledgeLibraryRebuildInput,
KnowledgeReferenceContext,
@@ -1639,6 +1640,12 @@ const desktopApi: DesktopApi = {
ipcChannels.knowledgeRebuildDocument,
input
) as Promise<KnowledgeSnapshot>,
openDocumentSource: async (input: KnowledgeDocumentOpenInput) => {
await ipcRenderer.invoke(
ipcChannels.knowledgeOpenDocumentSource,
input
)
},
rebuildLibrary: (input: KnowledgeLibraryRebuildInput) =>
ipcRenderer.invoke(
ipcChannels.knowledgeRebuildLibrary,
+83 -5
View File
@@ -894,6 +894,7 @@ const api: DesktopApi = {
graphRelations: [],
evidence: []
})),
openDocumentSource: vi.fn(async () => {}),
rebuildLibrary: vi.fn(async () => ({
rebuilt: 0,
failed: 0
@@ -4741,7 +4742,7 @@ describe('App', () => {
})
render(<App />)
const knowledgeScopeTrigger = await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个'
name: '选择知识库,本次已启用 0 个'
})
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-haspopup',
@@ -4774,6 +4775,7 @@ describe('App', () => {
name: /产品知识/u
})
await waitFor(() => expect(scopeCheckbox).toHaveFocus())
fireEvent.click(scopeCheckbox)
const composerInput = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.focusOut(scopeCheckbox, {
relatedTarget: composerInput
@@ -4880,6 +4882,78 @@ describe('App', () => {
)
})
it('restores an independent knowledge scope for each conversation', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
const firstConversationId =
'22222222-2222-4222-8222-222222222222'
const secondConversationId =
'33333333-3333-4333-8333-333333333333'
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
libraries: [
{
id: libraryId,
name: '产品知识',
description: '',
storageMode: 'managed',
graphEnabled: false,
graphStrategy: 'rules',
sourceCount: 1,
documentCount: 1,
indexedDocumentCount: 1
}
],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
})
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: firstConversationId,
projectId,
knowledgeLibraryIds: [libraryId],
knowledgeRetrievalMode: 'always',
title: '带知识范围',
updatedAt: 200,
messages: []
},
{
id: secondConversationId,
projectId,
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto',
title: '空知识范围',
updatedAt: 100,
messages: []
}
])
render(<App />)
expect(
await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个'
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: /^空知识范围/u })
)
expect(
screen.getByRole('button', {
name: '选择知识库,本次已启用 0 个'
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: /^带知识范围/u })
)
expect(
screen.getByRole('button', {
name: '选择知识库,本次已启用 1 个'
})
).toBeInTheDocument()
})
it('persists and submits always-retrieve mode for the active conversation', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
@@ -4904,9 +4978,12 @@ describe('App', () => {
})
render(<App />)
const knowledgeScope = await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个'
name: '选择知识库,本次已启用 0 个'
})
fireEvent.click(knowledgeScope)
fireEvent.click(
screen.getByRole('checkbox', { name: /产品知识/u })
)
const retrievalMode = screen.getByRole('group', {
name: '知识检索方式'
})
@@ -8410,6 +8487,7 @@ describe('App', () => {
provider: 'model',
profileId: modelProfileId
},
knowledgeLibraryIds: [libraryId],
knowledgeRetrievalMode: 'auto',
title: '元数据会话',
updatedAt: 1_775_000_000_000,
@@ -10591,8 +10669,8 @@ describe('App', () => {
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
fireEvent.click(
await screen.findByRole('button', {
name: /^工程知识 0 个文档/u
})
name: /^工程知识/u
}, { timeout: 5_000 })
)
expect(
await screen.findByText('知识库刷新失败')
@@ -10606,7 +10684,7 @@ describe('App', () => {
)
expect(
screen.getByRole('button', {
name: /^工程知识 0 个文档/u
name: /^工程知识/u
})
).toHaveAttribute('aria-current', 'page')
})
+79 -18
View File
@@ -767,6 +767,7 @@ function createConversation(
id: crypto.randomUUID(),
projectId,
runtimeSelection,
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto',
title: '新对话',
updatedAt: now,
@@ -1270,6 +1271,14 @@ function isConversation(value: unknown): value is Conversation {
(item.runtimeSelection === undefined ||
agentRuntimeSelectionSchema.safeParse(item.runtimeSelection)
.success) &&
(item.knowledgeLibraryIds === undefined ||
(Array.isArray(item.knowledgeLibraryIds) &&
item.knowledgeLibraryIds.length <= 20 &&
item.knowledgeLibraryIds.every(
(libraryId) =>
typeof libraryId === 'string' &&
assistantIdSchema.safeParse(libraryId).success
))) &&
(item.knowledgeRetrievalMode === undefined ||
item.knowledgeRetrievalMode === 'auto' ||
item.knowledgeRetrievalMode === 'always') &&
@@ -1358,6 +1367,7 @@ function toConversationSnapshots(
id: conversation.id,
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
@@ -1402,6 +1412,7 @@ function toLocalConversationHeader(
id: conversation.id,
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
@@ -2672,9 +2683,6 @@ function App(): React.JSX.Element {
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
undefined
)
const [enabledKnowledgeLibraryIds, setEnabledKnowledgeLibraryIds] = useState<
string[]
>([])
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
@@ -2714,7 +2722,6 @@ function App(): React.JSX.Element {
[]
)
const hydratingArtifactIds = useRef(new Set<string>())
const knowledgeScopeInitialized = useRef(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const [chatScrollSnapshots, setChatScrollSnapshots] = useState<
Record<string, ChatScrollSnapshot>
@@ -3246,6 +3253,34 @@ function App(): React.JSX.Element {
() => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations]
)
const enabledKnowledgeLibraryIds =
activeConversation?.knowledgeLibraryIds ?? []
const setEnabledKnowledgeLibraryIds = useCallback(
(action: SetStateAction<string[]>): void => {
setConversations((current) =>
current.map((conversation) => {
if (conversation.id !== activeId) {
return conversation
}
const previous = conversation.knowledgeLibraryIds ?? []
const next =
typeof action === 'function' ? action(previous) : action
if (
previous.length === next.length &&
previous.every((id, index) => id === next[index])
) {
return conversation
}
return {
...conversation,
knowledgeLibraryIds: next,
updatedAt: Date.now()
}
})
)
},
[activeId]
)
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects]
@@ -4017,18 +4052,22 @@ function App(): React.JSX.Element {
failedKnowledgeLibraryIdRef.current = undefined
setKnowledgeSnapshot(snapshot)
setKnowledgeLoadError(undefined)
if (!knowledgeScopeInitialized.current) {
knowledgeScopeInitialized.current = true
setEnabledKnowledgeLibraryIds(
snapshot.libraries.map((library) => library.id)
)
} else {
setEnabledKnowledgeLibraryIds((current) =>
current.filter((id) =>
snapshot.libraries.some((library) => library.id === id)
)
)
}
const availableIds = new Set(
snapshot.libraries.map((library) => library.id)
)
setConversations((current) =>
current.map((conversation) => {
const previous = conversation.knowledgeLibraryIds ?? []
const next = previous.filter((id) => availableIds.has(id))
return previous.length === next.length
? conversation
: {
...conversation,
knowledgeLibraryIds: next,
updatedAt: Date.now()
}
})
)
return snapshot
} catch (reason) {
if (requestId !== knowledgeLoadRequestRef.current) {
@@ -6173,7 +6212,10 @@ function App(): React.JSX.Element {
}, [refreshKnowledge])
useEffect(() => {
if (view !== 'knowledge' && knowledgeOperationCount === 0) {
if (
knowledgeLoadError ||
(view !== 'knowledge' && knowledgeOperationCount === 0)
) {
return
}
const interval = setInterval(() => {
@@ -6185,6 +6227,7 @@ function App(): React.JSX.Element {
}, knowledgeOperationCount > 0 ? 350 : 1_000)
return () => clearInterval(interval)
}, [
knowledgeLoadError,
knowledgeOperationCount,
knowledgeSnapshot.selectedLibraryId,
refreshKnowledge,
@@ -8248,7 +8291,6 @@ function App(): React.JSX.Element {
graphRelations: [],
evidence: []
})
setEnabledKnowledgeLibraryIds([])
updateAttachments([])
setInput('')
setView('chat')
@@ -10574,6 +10616,16 @@ function App(): React.JSX.Element {
)
)
}
onOpenDocumentSource={(libraryId, documentId) =>
window.goodbuddy.knowledge.openDocumentSource({
knowledgeBaseId: libraryId,
documentId
})
}
onOpenModelSettings={() => {
setSettingsInitialCategory('model')
setView('settings')
}}
onMergeEntities={(sourceId, targetId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.mergeEntities(
@@ -10606,6 +10658,15 @@ function App(): React.JSX.Element {
}).slice(0, 500)
})
}
onUseInChat={(libraryId) => {
setEnabledKnowledgeLibraryIds((current) =>
current.includes(libraryId)
? current
: [...current, libraryId]
)
setView('chat')
requestAnimationFrame(() => inputRef.current?.focus())
}}
onPauseSource={(sourceId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.pauseSource(sourceId)
@@ -9,12 +9,14 @@ export type KnowledgeEmbeddingIndexSectionProps = {
snapshot?: KnowledgeEmbeddingIndexSnapshot
loading?: boolean
onRebuild: () => void
onGoToSettings?: () => void
onViewTasks?: () => void
}
export function KnowledgeEmbeddingIndexSection({
snapshot,
loading = false,
onGoToSettings,
onRebuild,
onViewTasks
}: KnowledgeEmbeddingIndexSectionProps): React.JSX.Element {
@@ -52,6 +54,15 @@ export function KnowledgeEmbeddingIndexSection({
<div className="knowledge-embedding-index__empty">
<strong>{t('settings.vectorIndex.disabledTitle')}</strong>
<p>{t('settings.vectorIndex.disabledDescription')}</p>
{onGoToSettings && (
<button
className="secondary-button"
onClick={onGoToSettings}
type="button"
>
{t('actions.goToSettings')}
</button>
)}
</div>
) : (
<>
@@ -94,6 +94,18 @@ afterEach(async () => {
})
describe('KnowledgeRetrievalWorkbench', () => {
const openAdvancedSettings = (): void => {
fireEvent.click(screen.getByText('本次测试参数'))
}
it('keeps advanced retrieval parameters collapsed by default', () => {
render(<KnowledgeRetrievalWorkbench {...createProps()} />)
expect(
screen.getByText('本次测试参数').closest('details')
).not.toHaveAttribute('open')
})
it('provides dialog semantics, focuses the persistent query, and returns focus on close', async () => {
function Harness(): React.JSX.Element {
const [open, setOpen] = useState(false)
@@ -159,6 +171,7 @@ describe('KnowledgeRetrievalWorkbench', () => {
})}
/>
)
openAdvancedSettings()
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
@@ -182,6 +195,7 @@ describe('KnowledgeRetrievalWorkbench', () => {
})}
/>
)
openAdvancedSettings()
fireEvent.change(screen.getByLabelText(/^全文占比/), {
target: { value: '40' }
@@ -208,6 +222,7 @@ describe('KnowledgeRetrievalWorkbench', () => {
})}
/>
)
openAdvancedSettings()
expect(screen.getByLabelText(/^最低向量相似度/)).toHaveValue(0)
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
@@ -238,6 +253,7 @@ describe('KnowledgeRetrievalWorkbench', () => {
})}
/>
)
openAdvancedSettings()
expect(screen.getByText(/当前最多召回 24 个融合候选/u))
.toBeInTheDocument()
@@ -491,7 +491,7 @@ export function KnowledgeRetrievalWorkbench({
)}
</section>
<details className="knowledge-workbench-settings" open>
<details className="knowledge-workbench-settings">
<summary>
<span>
<strong>{t('retrieval.settings.title')}</strong>
+139 -11
View File
@@ -216,6 +216,7 @@ function createProps(
onUpdateChunk: vi.fn(),
onDeleteChunk: vi.fn(),
onRebuildDocument: vi.fn(),
onOpenDocumentSource: vi.fn(),
onRebuildLibrary: vi.fn(),
onCancelRebuild: vi.fn(),
onGetEmbeddingIndex: vi.fn(async () => ({
@@ -245,6 +246,8 @@ function createProps(
onCancelTask: vi.fn(),
onRetryTask: vi.fn(),
onOpenReferenceSource: vi.fn(),
onUseInChat: vi.fn(),
onOpenModelSettings: vi.fn(),
onMoveNode: vi.fn(),
onCreateEntity: vi.fn(),
onUpdateEntity: vi.fn(),
@@ -282,6 +285,7 @@ describe('KnowledgeWorkspace', () => {
fireEvent.change(screen.getByLabelText('描述'), {
target: { value: '访谈与反馈' }
})
fireEvent.click(screen.getByText('高级设置'))
fireEvent.click(screen.getByLabelText(/引用原文件/))
expect(
screen.getByRole('switch', { name: /启用知识图谱/u })
@@ -343,6 +347,96 @@ describe('KnowledgeWorkspace', () => {
)
})
it('confirms source removal before deleting indexed documents', async () => {
const onRemoveSource = vi.fn()
render(
<KnowledgeWorkspace
{...createProps({ onRemoveSource })}
/>
)
fireEvent.click(
screen.getByRole('button', { name: '移除来源 产品手册' })
)
expect(
screen.getByRole('alertdialog', {
name: '移除来源“产品手册”?'
})
).toHaveAccessibleDescription(
'将删除此来源的 1 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。'
)
expect(onRemoveSource).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('button', { name: '移除来源' })
)
await waitFor(() =>
expect(onRemoveSource).toHaveBeenCalledWith('source-1')
)
})
it('opens original documents, exposes index availability, and enters chat', () => {
const onOpenDocumentSource = vi.fn()
const onUseInChat = vi.fn()
render(
<KnowledgeWorkspace
{...createProps({ onOpenDocumentSource, onUseInChat })}
/>
)
expect(screen.getByText('全文可用')).toBeInTheDocument()
expect(screen.getByText('向量未启用')).toBeInTheDocument()
expect(
screen.getByText(
'1 篇可检索 · 0 篇处理中 · 0 篇需处理 · 共 1 篇'
)
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '打开 架构说明.md 的原始来源'
})
)
expect(onOpenDocumentSource).toHaveBeenCalledWith(
'library-1',
'document-1'
)
fireEvent.click(
screen.getByRole('button', { name: '用于当前对话' })
)
expect(onUseInChat).toHaveBeenCalledWith('library-1')
})
it('lets a failed document be retried while preserving available chunks', () => {
const onRebuildDocument = vi.fn()
const failedDocument = {
...createProps().documents[0]!,
status: 'failed' as const,
textIndexStatus: 'failed' as const,
vectorIndexStatus: 'failed' as const,
graphIndexStatus: 'failed' as const
}
render(
<KnowledgeWorkspace
{...createProps({
documents: [failedDocument],
onRebuildDocument
})}
/>
)
fireEvent.click(
screen.getByRole('button', {
name: '重新处理 架构说明.md'
})
)
expect(onRebuildDocument).toHaveBeenCalledWith(
'library-1',
'document-1'
)
expect(
screen.getByRole('button', { name: '文档分块' })
).toBeEnabled()
})
it('opens the retrieval workbench and runs an isolated test query', async () => {
const props = createProps()
const onRetrieve = vi.fn(props.onRetrieve)
@@ -603,12 +697,18 @@ describe('KnowledgeWorkspace', () => {
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(
screen.getByRole('heading', { name: '问答效果' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '测试并调整' })
).toBeInTheDocument()
expect(
await screen.findByRole('heading', { name: '向量索引' })
).toBeInTheDocument()
@@ -627,6 +727,32 @@ describe('KnowledgeWorkspace', () => {
})
})
it('links a disabled vector index to model settings', async () => {
const onOpenModelSettings = vi.fn()
render(
<KnowledgeWorkspace
{...createProps({
onGetEmbeddingIndex: vi.fn(async () => ({
knowledgeBaseId: library.id,
enabled: false,
coverage: { total: 1, indexed: 0, missing: 1, error: 0 },
indexStatus: { job: null }
})),
onOpenModelSettings
})}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '高级设置' }))
expect(
await screen.findByText('向量模型未启用')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '前往设置' })
)
expect(onOpenModelSettings).toHaveBeenCalledOnce()
})
it('hides graph navigation and graph-only controls until enabled', () => {
const disabledLibrary = {
...library,
@@ -640,11 +766,11 @@ describe('KnowledgeWorkspace', () => {
const tabs = screen.getByRole('tablist', { name: '知识库视图' })
expect(within(tabs).getAllByRole('tab').map((item) => item.textContent))
.toEqual(['文档与来源', '任务中心', '索引与检索'])
.toEqual(['文档与来源', '任务中心', '高级设置'])
expect(screen.queryByText('本次导入的图谱抽取策略'))
.not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
fireEvent.click(screen.getByRole('tab', { name: '高级设置' }))
expect(screen.getByRole('switch', { name: /启用知识图谱/u }))
.not.toBeChecked()
expect(
@@ -717,7 +843,7 @@ describe('KnowledgeWorkspace', () => {
})
const { rerender } = render(<KnowledgeWorkspace {...props} />)
fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
fireEvent.click(screen.getByRole('tab', { name: '高级设置' }))
rerender(
<KnowledgeWorkspace
{...props}
@@ -755,7 +881,7 @@ describe('KnowledgeWorkspace', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
fireEvent.click(screen.getByRole('tab', { name: '高级设置' }))
fireEvent.change(screen.getByLabelText('分块方式'), {
target: { value: 'parent-child' }
})
@@ -1185,7 +1311,7 @@ describe('KnowledgeWorkspace', () => {
)
expect(
screen.getByRole('columnheader', { name: '处理状态' })
screen.getByRole('columnheader', { name: '可用状态' })
).toBeInTheDocument()
expect(
screen.queryByRole('columnheader', { name: '索引进度' })
@@ -1317,7 +1443,7 @@ describe('KnowledgeWorkspace', () => {
fireEvent.click(mobileBack)
expect(workspace).toHaveClass('knowledge-workspace--mobile-list')
const selectedLibraryButton = screen.getByRole('button', {
name: /^产品知识 1 个文档/u
name: /^产品知识 1 篇可用 · 0 篇需处理/u
})
expect(selectedLibraryButton).toHaveClass(
'knowledge-workspace__library-button--selected'
@@ -1895,8 +2021,8 @@ describe('KnowledgeWorkspace', () => {
screen.queryByText('建立第一个知识库')
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: '新建知识库' })
).toBeDisabled()
screen.queryByRole('button', { name: '新建知识库' })
).not.toBeInTheDocument()
})
it('shows a retryable load error instead of the first-library empty state', () => {
@@ -1933,7 +2059,9 @@ describe('KnowledgeWorkspace', () => {
.toBeInTheDocument()
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /^产品知识 1 个文档/u })
screen.getByRole('button', {
name: /^产品知识 1 篇可用 · 0 篇需处理/u
})
).toHaveAttribute('aria-current', 'page')
})
+304 -22
View File
@@ -15,6 +15,7 @@ import {
Link2,
ListChecks,
LoaderCircle,
MessageSquare,
Network,
Pencil,
Plus,
@@ -302,6 +303,10 @@ export type KnowledgeWorkspaceProps = {
libraryId: string,
documentId: string
) => void | Promise<void>
onOpenDocumentSource: (
libraryId: string,
documentId: string
) => void | Promise<void>
onRebuildLibrary: (libraryId: string) => void | Promise<void>
onCancelRebuild: (libraryId: string) => void | Promise<void>
onGetEmbeddingIndex: (
@@ -319,6 +324,8 @@ export type KnowledgeWorkspaceProps = {
documentId: string
chunkId: string
}) => void | Promise<void>
onUseInChat: (libraryId: string) => void
onOpenModelSettings: () => void
onMoveNode: (
nodeId: string,
position: { x: number; y: number }
@@ -690,6 +697,9 @@ function CreateLibraryWizard({
<h2 className="knowledge-create__title">
{t('create.title')}
</h2>
<p className="knowledge-section-description">
{t('create.description')}
</p>
</div>
<label className="knowledge-field">
{t('fields.name')}
@@ -709,6 +719,14 @@ function CreateLibraryWizard({
value={description}
/>
</label>
<details className="knowledge-create__advanced">
<summary>
<span>
<strong>{t('create.advanced')}</strong>
<small>{t('create.advancedDescription')}</small>
</span>
</summary>
<div className="knowledge-create__advanced-content">
<fieldset className="knowledge-create__storage">
<legend>
{t('fields.storageMode')}
@@ -779,6 +797,8 @@ function CreateLibraryWizard({
</select>
</label>
)}
</div>
</details>
{error && (
<p
aria-live="polite"
@@ -1032,6 +1052,105 @@ function DeleteLibraryDialog({
)
}
function RemoveSourceDialog({
onCancel,
onConfirm,
source,
storageMode
}: {
onCancel: () => void
onConfirm: () => void | Promise<void>
source: KnowledgeSource
storageMode: KnowledgeStorageMode
}): React.JSX.Element {
const { t } = useTranslation('knowledge')
const [removing, setRemoving] = useState(false)
const [error, setError] = useState<string>()
const dialogRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
const descriptionId = useId()
useEffect(
() => activateModalFocus(() => cancelRef.current),
[]
)
const remove = async (): Promise<void> => {
setRemoving(true)
setError(undefined)
try {
await onConfirm()
onCancel()
} catch (reason) {
setError(toErrorMessage(reason, t))
} finally {
setRemoving(false)
}
}
return (
<div
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-modal="true"
className="knowledge-confirm-dialog-backdrop"
onKeyDown={(event) => {
if (event.key === 'Escape' && !removing) {
event.preventDefault()
onCancel()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="alertdialog"
>
<section className="knowledge-confirm-dialog">
<AlertCircle aria-hidden="true" size={26} />
<h2 id={titleId}>
{t('documents.removeSource.title', { name: source.name })}
</h2>
<p id={descriptionId}>
{t(
storageMode === 'managed'
? 'documents.removeSource.managedDescription'
: 'documents.removeSource.referenceDescription',
{ count: source.documentCount }
)}
</p>
{error && (
<p className="knowledge-inline-error" role="alert">
{error}
</p>
)}
<div className="knowledge-confirm-dialog__actions">
<button
className="secondary-button"
disabled={removing}
onClick={onCancel}
ref={cancelRef}
type="button"
>
{t('actions.cancel')}
</button>
<button
className="danger-button"
disabled={removing}
onClick={() => void remove()}
type="button"
>
<Trash2 aria-hidden="true" size={15} />
{removing
? t('documents.removeSource.removing')
: t('documents.removeSource.action')}
</button>
</div>
</section>
</div>
)
}
type GraphDestructiveAction =
| {
kind: 'delete-entity'
@@ -1179,9 +1298,11 @@ function DocumentsView({
onImportDirectory,
onImportFiles,
onImportUrl,
onOpenDocumentSource,
onPauseSource,
onManageChunks,
onRemoveSource,
onRebuildDocument,
onRetrySource,
onSyncSource,
onViewTasks,
@@ -1193,8 +1314,10 @@ function DocumentsView({
| 'onImportDirectory'
| 'onImportFiles'
| 'onImportUrl'
| 'onOpenDocumentSource'
| 'onPauseSource'
| 'onRemoveSource'
| 'onRebuildDocument'
| 'onRetrySource'
| 'onSyncSource'
| 'sources'
@@ -1216,6 +1339,8 @@ function DocumentsView({
const [dragging, setDragging] = useState(false)
const [pending, setPending] = useState<string>()
const [error, setError] = useState<string>()
const [removingSource, setRemovingSource] =
useState<KnowledgeSource>()
const [askStrategy, setAskStrategy] =
useState<Exclude<KnowledgeGraphStrategy, 'ask'>>('hybrid')
const importGraphStrategy =
@@ -1574,9 +1699,7 @@ function DocumentsView({
})}
className="danger-button danger-button--quiet"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onRemoveSource(source.id))
}
onClick={() => setRemovingSource(source)}
type="button"
>
<Trash2 aria-hidden="true" size={14} />
@@ -1671,6 +1794,34 @@ function DocumentsView({
>
{t(documentStatusLabelKeys[document.status])}
</span>
<span className="knowledge-document-status__channels">
<span>
{t(
`indexStatuses.text.${
document.textIndexStatus ??
(document.status === 'ready'
? 'ready'
: document.status === 'failed'
? 'failed'
: 'waiting')
}`
)}
</span>
<span>
{t(
`indexStatuses.vector.${
document.vectorIndexStatus ?? 'disabled'
}`
)}
</span>
{document.graphIndexStatus !== undefined && (
<span>
{t(
`indexStatuses.graph.${document.graphIndexStatus}`
)}
</span>
)}
</span>
{activeTask && (
<span className="knowledge-document-status__active">
{t(taskStageLabelKeys[activeTask.stage])}
@@ -1711,8 +1862,49 @@ function DocumentsView({
</button>
)}
<button
aria-label={t(
'documents.actions.openDocumentSource',
{ name: document.name }
)}
className="secondary-button"
disabled={document.status !== 'ready'}
disabled={pending === `open:${document.id}`}
onClick={() =>
void run(`open:${document.id}`, () =>
onOpenDocumentSource(
library.id,
document.id
)
)
}
type="button"
>
{t('actions.openSource')}
</button>
{document.status === 'failed' && (
<button
aria-label={t(
'documents.actions.retryDocument',
{ name: document.name }
)}
className="secondary-button"
disabled={pending === `retry:${document.id}`}
onClick={() =>
void run(`retry:${document.id}`, () =>
onRebuildDocument(
library.id,
document.id
)
)
}
type="button"
>
<RotateCcw aria-hidden="true" size={14} />
{t('actions.retryDocument')}
</button>
)}
<button
className="secondary-button"
disabled={(document.chunkCount ?? 0) === 0}
onClick={() => onManageChunks(document)}
type="button"
>
@@ -1728,6 +1920,14 @@ function DocumentsView({
</div>
)}
</section>
{removingSource && (
<RemoveSourceDialog
onCancel={() => setRemovingSource(undefined)}
onConfirm={() => onRemoveSource(removingSource.id)}
source={removingSource}
storageMode={library.storageMode}
/>
)}
</div>
)
}
@@ -1992,6 +2192,8 @@ function KnowledgeSettingsView({
mode,
onCancelRebuild,
onGetEmbeddingIndex,
onOpenModelSettings,
onOpenRetrieval,
onRebuildLibrary,
onRebuildEmbeddingIndex,
onViewTasks,
@@ -2002,6 +2204,8 @@ function KnowledgeSettingsView({
mode: 'index' | 'graph'
onCancelRebuild: KnowledgeWorkspaceProps['onCancelRebuild']
onGetEmbeddingIndex: KnowledgeWorkspaceProps['onGetEmbeddingIndex']
onOpenModelSettings: KnowledgeWorkspaceProps['onOpenModelSettings']
onOpenRetrieval: () => void
onRebuildLibrary: KnowledgeWorkspaceProps['onRebuildLibrary']
onRebuildEmbeddingIndex:
KnowledgeWorkspaceProps['onRebuildEmbeddingIndex']
@@ -2220,9 +2424,30 @@ function KnowledgeSettingsView({
<div
className={`knowledge-settings knowledge-settings--${mode}`}
>
{mode === 'index' && (
<section className="knowledge-settings__section">
<div>
<h3>{t('settings.retrieval.title')}</h3>
<p className="knowledge-section-description">
{t('settings.retrieval.description')}
</p>
</div>
<div className="knowledge-settings__actions">
<button
className="primary-button"
onClick={onOpenRetrieval}
type="button"
>
<Search aria-hidden="true" size={15} />
{t('settings.retrieval.action')}
</button>
</div>
</section>
)}
{mode === 'index' && (
<KnowledgeEmbeddingIndexSection
loading={embeddingIndexLoading}
onGoToSettings={onOpenModelSettings}
onRebuild={() => void rebuildEmbeddingIndex()}
onViewTasks={onViewTasks}
snapshot={embeddingIndex}
@@ -4096,6 +4321,8 @@ export function KnowledgeWorkspace({
onImportFiles,
onImportDirectory,
onImportUrl,
onOpenDocumentSource,
onOpenModelSettings,
onSyncSource,
onPauseSource,
onRetrySource,
@@ -4113,6 +4340,7 @@ export function KnowledgeWorkspace({
onCancelTask,
onRetryTask,
onOpenReferenceSource,
onUseInChat,
onMoveNode,
onCreateEntity,
onUpdateEntity,
@@ -4279,6 +4507,7 @@ export function KnowledgeWorkspace({
<div className="knowledge-page">
<PageHeader
actions={
libraries.length > 0 ? (
<button
className="primary-button"
disabled={loading}
@@ -4291,6 +4520,7 @@ export function KnowledgeWorkspace({
<Plus aria-hidden="true" size={16} />
{t('actions.newLibrary')}
</button>
) : undefined
}
description={t('page.description')}
headingId="knowledge-workspace-title"
@@ -4328,9 +4558,13 @@ export function KnowledgeWorkspace({
{libraries.map((library) => {
const selected = library.id === selectedLibrary?.id
const libraryMeta = t('workspace.libraryMeta', {
count: formatNumber(library.documentCount, locale),
storageMode: t(
storageModeLabelKeys[library.storageMode]
ready: formatNumber(
library.indexedDocumentCount,
locale
),
failed: formatNumber(
library.failedDocumentCount ?? 0,
locale
)
})
return (
@@ -4430,6 +4664,16 @@ export function KnowledgeWorkspace({
/>
) : !selectedLibrary ? (
<EmptyState
action={
<button
className="primary-button"
onClick={() => setCreating(true)}
type="button"
>
<Plus aria-hidden="true" size={16} />
{t('actions.newLibrary')}
</button>
}
description={t('empty.description')}
icon={<BookOpen size={34} />}
level="page"
@@ -4453,27 +4697,49 @@ export function KnowledgeWorkspace({
<h2>
{selectedLibrary.name}
</h2>
{selectedLibrary.description && (
<p className="knowledge-workspace__description">
{selectedLibrary.description}
</p>
)}
<p className="knowledge-workspace__description">
{selectedLibrary.description ||
t('workspace.librarySummary', {
sourceCount: formatNumber(
selectedLibrary.sourceCount,
locale
),
indexedCount: formatNumber(
selectedLibrary.indexedDocumentCount,
locale
),
documentCount: formatNumber(
selectedLibrary.documentCount,
locale
)
})}
{t('workspace.librarySummary', {
ready: formatNumber(
selectedLibrary.indexedDocumentCount,
locale
),
processing: formatNumber(
selectedLibrary.processingDocumentCount ?? 0,
locale
),
failed: formatNumber(
selectedLibrary.failedDocumentCount ?? 0,
locale
),
documentCount: formatNumber(
selectedLibrary.documentCount,
locale
)
})}
</p>
</div>
<div
className="knowledge-workspace__header-actions"
>
<button
className="secondary-button"
disabled={selectedLibrary.indexedDocumentCount === 0}
onClick={() => onUseInChat(selectedLibrary.id)}
title={t(
selectedLibrary.indexedDocumentCount > 0
? 'workspace.readyForQuestions'
: 'workspace.waitingForDocuments'
)}
type="button"
>
<MessageSquare aria-hidden="true" size={15} />
{t('actions.useInChat')}
</button>
<button
className="secondary-button"
onClick={() => {
@@ -4532,9 +4798,11 @@ export function KnowledgeWorkspace({
onImportDirectory={onImportDirectory}
onImportFiles={onImportFiles}
onImportUrl={onImportUrl}
onOpenDocumentSource={onOpenDocumentSource}
onManageChunks={openChunkManager}
onPauseSource={onPauseSource}
onRemoveSource={onRemoveSource}
onRebuildDocument={onRebuildDocument}
onRetrySource={onRetrySource}
onSyncSource={onSyncSource}
onViewTasks={(context) => {
@@ -4598,6 +4866,13 @@ export function KnowledgeWorkspace({
mode="graph"
onCancelRebuild={onCancelRebuild}
onGetEmbeddingIndex={onGetEmbeddingIndex}
onOpenModelSettings={onOpenModelSettings}
onOpenRetrieval={() => {
setRetrievalStatus('idle')
setRetrievalError(undefined)
setRetrievalResponse(undefined)
setRetrievalOpen(true)
}}
onRebuildLibrary={onRebuildLibrary}
onRebuildEmbeddingIndex={onRebuildEmbeddingIndex}
onViewTasks={() => {
@@ -4627,6 +4902,13 @@ export function KnowledgeWorkspace({
mode="index"
onCancelRebuild={onCancelRebuild}
onGetEmbeddingIndex={onGetEmbeddingIndex}
onOpenModelSettings={onOpenModelSettings}
onOpenRetrieval={() => {
setRetrievalStatus('idle')
setRetrievalError(undefined)
setRetrievalResponse(undefined)
setRetrievalOpen(true)
}}
onRebuildLibrary={onRebuildLibrary}
onRebuildEmbeddingIndex={onRebuildEmbeddingIndex}
onViewTasks={() => {
@@ -23,6 +23,9 @@ export const knowledge = {
retry: 'Retry',
sync: 'Sync',
remove: 'Remove',
openSource: 'Open source',
retryDocument: 'Process again',
useInChat: 'Use in this conversation',
saveEntity: 'Save entity',
addEntity: 'Add entity',
saveRelation: 'Save relation',
@@ -80,6 +83,27 @@ export const knowledge = {
ready: 'Indexed',
failed: 'Processing failed'
},
indexStatuses: {
text: {
waiting: 'Full text pending',
ready: 'Full text ready',
failed: 'Full text unavailable'
},
vector: {
disabled: 'Vectors disabled',
waiting: 'Vectors processing',
ready: 'Vectors ready',
failed: 'Vectors failed',
missing: 'Vectors pending'
},
graph: {
disabled: 'Graph disabled',
'on-demand': 'Graph on demand',
waiting: 'Graph processing',
ready: 'Graph ready',
failed: 'Graph failed'
}
},
taskKinds: {
sourceSync: 'Source sync',
documentProcess: 'Document processing',
@@ -138,7 +162,12 @@ export const knowledge = {
create: {
ariaLabel: 'Create library',
eyebrow: 'New library',
title: 'Create library'
title: 'Create library',
description:
'Name the library, then add files, folders, or web pages and use it in this conversation.',
advanced: 'Advanced settings',
advancedDescription:
'Change these only when you need managed copies or a knowledge graph.'
},
edit: {
ariaLabel: 'Edit library',
@@ -526,7 +555,19 @@ export const knowledge = {
pauseSource: 'Pause {{name}}',
retrySource: 'Retry {{name}}',
syncSource: 'Sync {{name}}',
removeSource: 'Remove source {{name}}'
removeSource: 'Remove source {{name}}',
openDocumentSource: 'Open the original source for {{name}}',
retryDocument: 'Process {{name}} again'
},
removeSource: {
ariaLabel: 'Confirm source removal',
title: 'Remove source “{{name}}”?',
managedDescription:
'This removes {{count}} documents, their retrieval indexes and graph evidence, plus the app-managed copy. Original files on disk are not changed.',
referenceDescription:
'This removes {{count}} documents, their retrieval indexes, and graph evidence. Original files on disk are not changed.',
action: 'Remove source',
removing: 'Removing…'
},
table: {
title: 'Documents and index',
@@ -537,7 +578,7 @@ export const knowledge = {
document: 'Document',
status: 'Status',
indexProgress: 'Index progress',
processingStatus: 'Processing status',
processingStatus: 'Availability',
chunks: 'Chunks',
size: 'Size',
actions: 'Actions'
@@ -573,6 +614,12 @@ export const knowledge = {
description:
'Control how new imports, resyncs, and explicit rebuilds generate entities, relations, and evidence.'
},
retrieval: {
title: 'Answer quality',
description:
'Test a real question against this library. Advanced retrieval parameters can be adjusted and saved in the test workbench.',
action: 'Test and adjust'
},
chunking: {
title: 'Chunking strategy',
description:
@@ -712,19 +759,23 @@ export const knowledge = {
documents: 'Documents and sources',
graph: 'Knowledge graph',
tasks: 'Task center',
settings: 'Index and retrieval'
settings: 'Advanced settings'
},
workspace: {
ariaLabel: 'Knowledge workspace',
libraryList: 'Library list',
libraryListEmpty:
'Create a library to manage reusable sources, indexes, and entity relations.',
libraryMeta: '{{count}} documents · {{storageMode}}',
libraryMeta: '{{ready}} ready · {{failed}} need attention',
detailsAriaLabel: 'Library details',
tabsAriaLabel: 'Knowledge base views',
scopeGlobal: 'Global',
librarySummary:
'{{sourceCount}} sources, {{indexedCount}}/{{documentCount}} documents indexed.'
'{{ready}} searchable · {{processing}} processing · {{failed}} need attention · {{documentCount}} total',
readyForQuestions:
'Your content is searchable. Add this library to the current conversation and start asking questions.',
waitingForDocuments:
'Add content and wait for processing before using this library in a conversation.'
},
loading: {
title: 'Loading knowledge base',
@@ -20,6 +20,9 @@ export const knowledge = {
retry: '重试',
sync: '同步',
remove: '移除',
openSource: '打开来源',
retryDocument: '重新处理',
useInChat: '用于当前对话',
saveEntity: '保存实体',
addEntity: '新增实体',
saveRelation: '保存关系',
@@ -76,6 +79,27 @@ export const knowledge = {
ready: '索引完成',
failed: '处理失败'
},
indexStatuses: {
text: {
waiting: '全文待建立',
ready: '全文可用',
failed: '全文不可用'
},
vector: {
disabled: '向量未启用',
waiting: '向量处理中',
ready: '向量可用',
failed: '向量失败',
missing: '向量待建立'
},
graph: {
disabled: '图谱未启用',
'on-demand': '图谱按需生成',
waiting: '图谱处理中',
ready: '图谱可用',
failed: '图谱失败'
}
},
taskKinds: {
sourceSync: '来源同步',
documentProcess: '文档处理',
@@ -134,7 +158,10 @@ export const knowledge = {
create: {
ariaLabel: '创建知识库',
eyebrow: '新建知识库',
title: '创建知识库'
title: '创建知识库',
description: '命名后即可添加文件、文件夹或网页,并在当前对话中使用。',
advanced: '高级设置',
advancedDescription: '仅在需要托管副本或知识图谱时调整。'
},
edit: {
ariaLabel: '编辑知识库',
@@ -492,7 +519,19 @@ export const knowledge = {
pauseSource: '暂停 {{name}}',
retrySource: '重试 {{name}}',
syncSource: '同步 {{name}}',
removeSource: '移除来源 {{name}}'
removeSource: '移除来源 {{name}}',
openDocumentSource: '打开 {{name}} 的原始来源',
retryDocument: '重新处理 {{name}}'
},
removeSource: {
ariaLabel: '移除来源确认',
title: '移除来源“{{name}}”?',
managedDescription:
'将删除此来源的 {{count}} 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。',
referenceDescription:
'将删除此来源的 {{count}} 篇文档、检索索引和图谱证据。磁盘上的原始文件不会改变。',
action: '移除来源',
removing: '正在移除…'
},
table: {
title: '文档与索引',
@@ -502,7 +541,7 @@ export const knowledge = {
document: '文档',
status: '状态',
indexProgress: '索引进度',
processingStatus: '处理状态',
processingStatus: '可用状态',
chunks: '分块',
size: '大小',
actions: '操作'
@@ -534,6 +573,12 @@ export const knowledge = {
title: '抽取方式',
description: '控制新导入、重新同步和显式重建时如何生成实体、关系与证据。'
},
retrieval: {
title: '问答效果',
description:
'输入真实问题验证当前知识库;高级检索参数可以在测试工作台中调整并保存。',
action: '测试并调整'
},
chunking: {
title: '分块策略',
description:
@@ -666,19 +711,21 @@ export const knowledge = {
documents: '文档与来源',
graph: '知识图谱',
tasks: '任务中心',
settings: '索引与检索'
settings: '高级设置'
},
workspace: {
ariaLabel: '知识工作区',
libraryList: '知识库列表',
libraryListEmpty:
'创建知识库,集中管理可跨项目使用的来源、索引和实体关系。',
libraryMeta: '{{count}} 个文档 · {{storageMode}}',
libraryMeta: '{{ready}} 篇可用 · {{failed}} 篇需处理',
detailsAriaLabel: '知识库详情',
tabsAriaLabel: '知识库视图',
scopeGlobal: '全局',
librarySummary:
'{{sourceCount}} 个来源,{{indexedCount}}/{{documentCount}} 个文档已完成索引。'
'{{ready}} 篇可检索 · {{processing}} 篇处理中 · {{failed}} 篇需处理 · 共 {{documentCount}} 篇',
readyForQuestions: '资料已可检索,可以加入当前对话开始提问。',
waitingForDocuments: '添加资料并完成处理后,即可在对话中使用。'
},
loading: {
title: '正在加载知识库',
+40
View File
@@ -12525,6 +12525,32 @@ details.settings-section > :not(summary) + :not(summary) {
font-size: 22px;
}
.knowledge-create__advanced {
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
}
.knowledge-create__advanced > summary {
padding: var(--space-3);
cursor: pointer;
}
.knowledge-create__advanced > summary span,
.knowledge-create__advanced-content {
display: grid;
gap: var(--space-2);
}
.knowledge-create__advanced > summary small {
color: var(--text-muted);
font-weight: 400;
}
.knowledge-create__advanced-content {
padding: 0 var(--space-3) var(--space-3);
}
.knowledge-create textarea,
.knowledge-graph__form textarea {
resize: vertical;
@@ -13332,6 +13358,20 @@ details.settings-section > :not(summary) + :not(summary) {
font-size: var(--font-caption);
}
.knowledge-document-status__channels {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.knowledge-document-status__channels > span {
padding: 2px var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
color: var(--text-secondary);
font-size: var(--font-caption);
}
.knowledge-graph {
display: grid;
min-height: clamp(500px, calc(100dvh - 260px), 780px);
+32
View File
@@ -298,6 +298,38 @@ describe('conversation branch contracts', () => {
})
})
describe('conversation knowledge scope contracts', () => {
it('persists a bounded per-conversation knowledge library selection', () => {
const knowledgeLibraryIds = [
'00000000-0000-4000-8000-000000000301',
'00000000-0000-4000-8000-000000000302'
]
expect(
conversationSnapshotSchema.parse({
id: '00000000-0000-4000-8000-000000000303',
knowledgeLibraryIds,
knowledgeRetrievalMode: 'always',
title: '知识问答',
updatedAt: 1,
messages: []
}).knowledgeLibraryIds
).toEqual(knowledgeLibraryIds)
expect(
conversationSnapshotSchema.safeParse({
id: '00000000-0000-4000-8000-000000000303',
knowledgeLibraryIds: Array.from(
{ length: 21 },
(_, index) =>
`00000000-0000-4000-8000-${String(index).padStart(12, '0')}`
),
title: '知识问答',
updatedAt: 1,
messages: []
}).success
).toBe(false)
})
})
describe('conversation persistence contracts', () => {
it('preserves complete tool details without persistence length limits', () => {
const input = 'input'.repeat(10_000)
+1
View File
@@ -485,6 +485,7 @@ export const conversationSnapshotSchema = z
id: assistantIdSchema,
projectId: assistantIdSchema.optional(),
runtimeSelection: agentRuntimeSelectionSchema.optional(),
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
contextMetrics: conversationContextMetricsSchema.optional(),
contextCompressionState:
+9
View File
@@ -139,6 +139,7 @@ import type {
KnowledgeChunkPage,
KnowledgeChunkUpdateInput,
KnowledgeChunksListInput,
KnowledgeDocumentOpenInput,
KnowledgeDocumentRebuildInput,
KnowledgeLibraryRebuildInput,
KnowledgeReferenceContext,
@@ -1477,6 +1478,8 @@ export type KnowledgeLibrary = z.infer<typeof knowledgeCreateSchema> & {
sourceCount: number
documentCount: number
indexedDocumentCount: number
processingDocumentCount?: number
failedDocumentCount?: number
retrievalSettings?: KnowledgeRetrievalSettings
chunkingSettings?: KnowledgeChunkingSettings
chunkingRebuildRequired?: boolean
@@ -1505,6 +1508,9 @@ export type KnowledgeDocumentItem = {
name: string
path?: string
status: 'queued' | 'parsing' | 'indexing' | 'ready' | 'failed'
textIndexStatus?: 'waiting' | 'ready' | 'failed'
vectorIndexStatus?: 'disabled' | 'waiting' | 'ready' | 'failed' | 'missing'
graphIndexStatus?: 'disabled' | 'on-demand' | 'waiting' | 'ready' | 'failed'
indexProgress?: number
chunkCount?: number
size?: number
@@ -2144,6 +2150,9 @@ export type DesktopApi = {
rebuildDocument: (
input: KnowledgeDocumentRebuildInput
) => Promise<KnowledgeSnapshot>
openDocumentSource: (
input: KnowledgeDocumentOpenInput
) => Promise<void>
rebuildLibrary: (
input: KnowledgeLibraryRebuildInput
) => Promise<{ rebuilt: number; failed: number }>
+1
View File
@@ -276,6 +276,7 @@ export const ipcChannels = {
knowledgeUpdateChunk: 'knowledge:chunk:update',
knowledgeDeleteChunk: 'knowledge:chunk:delete',
knowledgeRebuildDocument: 'knowledge:document:rebuild',
knowledgeOpenDocumentSource: 'knowledge:document:open-source',
knowledgeRebuildLibrary: 'knowledge:library:rebuild',
knowledgeCancelRebuild: 'knowledge:library:rebuild:cancel',
knowledgeEmbeddingIndexGet: 'knowledge:embedding-index:get',
+17
View File
@@ -4,6 +4,7 @@ import {
defaultKnowledgeRetrievalSettings,
knowledgeChunkingSettingsSchema,
knowledgeChunkUpdateInputSchema,
knowledgeDocumentOpenInputSchema,
knowledgeRetrievalSettingsSchema,
knowledgeRetrieveInputSchema
} from './knowledge-contracts'
@@ -85,5 +86,21 @@ describe('knowledge contracts', () => {
chunkId: 'chunk'
}).success
).toBe(false)
expect(
knowledgeDocumentOpenInputSchema.parse({
knowledgeBaseId: 'library',
documentId: 'document'
})
).toEqual({
knowledgeBaseId: 'library',
documentId: 'document'
})
expect(
knowledgeDocumentOpenInputSchema.safeParse({
knowledgeBaseId: 'library',
documentId: 'document',
path: 'C:\\untrusted.docx'
}).success
).toBe(false)
})
})
+10
View File
@@ -315,6 +315,16 @@ export type KnowledgeDocumentRebuildInput = z.infer<
typeof knowledgeDocumentRebuildInputSchema
>
export const knowledgeDocumentOpenInputSchema = z
.object({
knowledgeBaseId: idSchema,
documentId: idSchema
})
.strict()
export type KnowledgeDocumentOpenInput = z.infer<
typeof knowledgeDocumentOpenInputSchema
>
export const knowledgeLibraryRebuildInputSchema = z
.object({
knowledgeBaseId: idSchema