feat: enhance knowledge workflows and refresh interface

This commit is contained in:
lofyer
2026-08-10 16:27:40 +08:00
parent 2cb712e4ba
commit 0fab985f28
26 changed files with 4088 additions and 1183 deletions
+24
View File
@@ -520,6 +520,20 @@ function getKnowledgeSnapshot(
documentsById.get(item.documentId)?.title ?? '未知文档',
excerpt: item.quote ?? '',
location: item.location
})),
tasks: snapshot.tasks.map((task) => ({
id: task.id,
libraryId: task.libraryId,
sourceId: task.sourceId,
documentId: task.documentId,
documentName: task.documentName,
kind: task.kind,
status: task.status,
progress: task.progress,
message: task.message,
createdAt: task.createdAt,
startedAt: task.startedAt,
completedAt: task.completedAt
}))
}
}
@@ -3515,12 +3529,22 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const value = knowledgeUpdateLibrarySchema.parse(input)
knowledgeService.database.updateKnowledgeBase(value.libraryId, {
name: value.name,
description: value.description,
graphEnabled: value.graphEnabled,
graphStrategy: value.graphStrategy
})
}
)
ipcMain.handle(
ipcChannels.knowledgeReextractGraph,
async (event, input: unknown) => {
assertTrustedSender(event, window)
return knowledgeService.reextractGraph(knowledgeIdSchema.parse(input))
}
)
ipcMain.handle(
ipcChannels.knowledgeSelectFiles,
async (event, input: unknown) => {
+25 -5
View File
@@ -348,6 +348,28 @@ describe('extraction strategies', () => {
)
})
it('propagates model extraction failures for hybrid and model strategies', async () => {
const chunks = [{ id: 'fallback', content: '# Local Entity' }]
for (const strategy of ['hybrid', 'model'] as const) {
await expect(
extractKnowledgeGraph(chunks, {
strategy,
extractStructured: async () => {
throw new Error('模型未返回图谱内容')
}
})
).rejects.toThrow('模型未返回图谱内容')
}
await expect(
extractKnowledgeGraph(chunks, {
strategy: 'hybrid',
extractStructured: async () => {
return { invalid: true }
}
})
).rejects.toThrow()
})
it('supports rules, model, and ask behavior without an implicit model call', async () => {
const chunks = [{ id: 'strategy', content: '# Local Entity' }]
const callback = vi.fn()
@@ -359,14 +381,12 @@ describe('extraction strategies', () => {
strategy: 'ask',
extractStructured: callback
})
const unavailable = await extractKnowledgeGraph(chunks, {
strategy: 'model'
})
expect(callback).not.toHaveBeenCalled()
expect(rules.requiresModelApproval).toBe(false)
expect(ask.requiresModelApproval).toBe(true)
expect(unavailable.warnings).toEqual(['Model extraction is unavailable'])
await expect(
extractKnowledgeGraph(chunks, { strategy: 'model' })
).rejects.toThrow('Model extraction is unavailable')
})
it('honors cancellation before and after the injected model callback', async () => {
+6 -7
View File
@@ -679,12 +679,7 @@ export async function extractKnowledgeGraph(
}
}
if (!options.extractStructured) {
return {
...rules,
strategy,
requiresModelApproval: false,
warnings: ['Model extraction is unavailable']
}
throw new Error('Model extraction is unavailable')
}
const output = await options.extractStructured(
@@ -692,7 +687,11 @@ export async function extractKnowledgeGraph(
options.signal
)
throwIfAborted(options.signal)
const model = validateModelGraph(output, prepared)
const parsedOutput = parseModelOutput(output)
if (!modelEnvelopeSchema.safeParse(parsedOutput).success) {
throw new Error('模型返回的图谱结构无效')
}
const model = validateModelGraph(parsedOutput, prepared)
const graph =
strategy === 'hybrid' ? mergeKnowledgeGraphs(rules, model) : model
return {
+48
View File
@@ -905,6 +905,54 @@ export class KnowledgeDatabase {
)
}
pruneUnreferencedGeneratedGraph(knowledgeBaseId: string): {
entities: number
relations: number
} {
const normalizedId = requiredString(
knowledgeBaseId,
'knowledgeBaseId',
MAX_ID_LENGTH
)
const database = this.requireDatabase()
let entities = 0
let relations = 0
this.transaction(database, () => {
relations = Number(
database
.prepare(
`DELETE FROM graph_relations
WHERE knowledge_base_id = ?
AND locked = 0
AND NOT EXISTS (
SELECT 1 FROM graph_evidence
WHERE relation_id = graph_relations.id
)`
)
.run(normalizedId).changes
)
entities = Number(
database
.prepare(
`DELETE FROM graph_entities
WHERE knowledge_base_id = ?
AND locked = 0
AND NOT EXISTS (
SELECT 1 FROM graph_evidence
WHERE entity_id = graph_entities.id
)
AND NOT EXISTS (
SELECT 1 FROM graph_relations
WHERE source_entity_id = graph_entities.id
OR target_entity_id = graph_entities.id
)`
)
.run(normalizedId).changes
)
})
return { entities, relations }
}
listChunks(documentId: string, limit = MAX_LIST_LIMIT): Chunk[] {
const normalizedId = requiredString(
documentId,
+97 -2
View File
@@ -8,6 +8,7 @@ import {
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ExtractStructured } from './graph-extractor'
import { KnowledgeService } from './knowledge-service'
import type { EmbeddingProvider } from './types'
import { UrlImporter } from './url-importer'
@@ -17,7 +18,8 @@ const services: KnowledgeService[] = []
async function createService(
urlImporter?: UrlImporter,
embeddingProvider?: EmbeddingProvider
embeddingProvider?: EmbeddingProvider,
extractStructured?: ExtractStructured
): Promise<{ directory: string; service: KnowledgeService }> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-service-'))
temporaryDirectories.push(directory)
@@ -25,7 +27,8 @@ async function createService(
databasePath: join(directory, 'knowledge.sqlite'),
managedRoot: join(directory, 'managed'),
urlImporter,
embeddingProvider
embeddingProvider,
extractStructured
})
await service.initialize()
services.push(service)
@@ -145,9 +148,101 @@ describe('KnowledgeService', () => {
expect(snapshot.entities.length).toBeGreaterThan(0)
expect(snapshot.evidence.length).toBeGreaterThan(0)
expect(snapshot.tasks).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'parsing',
status: 'succeeded',
progress: 100
}),
expect.objectContaining({
kind: 'embedding',
status: 'skipped',
progress: 100
}),
expect.objectContaining({
kind: 'graph',
status: 'succeeded',
progress: 100
})
])
)
await service.dispose()
})
it('reextracts graph evidence and removes only stale generated entities', async () => {
const { directory, service } = await createService()
const sourcePath = join(directory, 'reextract.md')
await writeFile(
sourcePath,
'GoodBuddy(产品)依赖 Electron(框架)。',
'utf8'
)
const library = service.createLibrary({
name: '重新抽取',
storageMode: 'reference',
graphEnabled: true,
graphStrategy: 'rules'
})
await service.importPaths(library.id, [sourcePath])
const stale = service.database.createEntity({
knowledgeBaseId: library.id,
name: '过期实体',
type: '概念',
locked: false
})
const manual = service.database.createEntity({
knowledgeBaseId: library.id,
name: '人工实体',
type: '概念',
locked: true
})
await service.reextractGraph(library.id)
const snapshot = service.snapshot(library.id)
expect(snapshot.evidence.length).toBeGreaterThan(0)
expect(service.database.getEntity(stale.id)).toBeUndefined()
expect(service.database.getEntity(manual.id)).toBeDefined()
})
it('fails hybrid reextraction when model extraction fails', async () => {
const extractStructured = vi.fn(async () => {
throw new Error('模型未返回图谱内容')
})
const { directory, service } = await createService(
undefined,
undefined,
extractStructured
)
const sourcePath = join(directory, 'hybrid-fallback.md')
await writeFile(sourcePath, '# 本地实体', 'utf8')
const library = service.createLibrary({
name: '混合抽取',
storageMode: 'reference',
graphEnabled: false,
graphStrategy: 'hybrid'
})
await service.importPaths(library.id, [sourcePath])
service.database.updateKnowledgeBase(library.id, {
graphEnabled: true
})
await expect(service.reextractGraph(library.id)).rejects.toThrow(
'模型未返回图谱内容'
)
expect(service.snapshot(library.id).entities).toHaveLength(0)
expect(service.snapshot(library.id).tasks).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'graph',
status: 'failed',
message: '模型未返回图谱内容'
})
])
)
})
it('indexes optional embeddings and performs vector-backed hybrid search', async () => {
const provider: EmbeddingProvider = {
provider: 'test-provider',
+339 -6
View File
@@ -23,7 +23,8 @@ import { classifyEmbeddingError } from './embedding-errors'
import {
extractKnowledgeGraph,
normalizeEntityAlias,
type ExtractStructured
type ExtractStructured,
type GraphExtractionResult
} from './graph-extractor'
import { KnowledgeDatabase } from './knowledge-database'
import type {
@@ -66,6 +67,21 @@ export type KnowledgeDocumentSnapshot = Document & {
error?: string
}
export type KnowledgeTaskSnapshot = {
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 KnowledgeSnapshot = {
libraries: KnowledgeLibrarySnapshot[]
sources: KnowledgeSourceSnapshot[]
@@ -73,6 +89,7 @@ export type KnowledgeSnapshot = {
entities: GraphEntity[]
relations: GraphRelation[]
evidence: ReturnType<KnowledgeDatabase['listEvidence']>
tasks: KnowledgeTaskSnapshot[]
}
export type KnowledgeServiceOptions = {
@@ -89,6 +106,7 @@ const maximumFileBytes = 20 * 1024 * 1024
const maximumSourceBytes = 500 * 1024 * 1024
const maximumFilesPerSource = 2_000
const maximumEmbeddingChunksPerBatch = 32
const maximumKnowledgeTasks = 500
function isInside(root: string, candidate: string): boolean {
const path = relative(resolve(root), resolve(candidate))
@@ -105,6 +123,7 @@ export class KnowledgeService {
private readonly watchers = new Map<string, FSWatcher>()
private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>()
private readonly activeSyncs = new Map<string, Promise<void>>()
private readonly tasks = new Map<string, KnowledgeTaskSnapshot>()
private readonly lifecycleController = new AbortController()
constructor(options: KnowledgeServiceOptions) {
@@ -163,6 +182,109 @@ export class KnowledgeService {
return Promise.resolve()
}
private createKnowledgeTask(input: {
libraryId: string
sourceId?: string
documentId?: string
documentName: string
kind: KnowledgeTaskSnapshot['kind']
status?: KnowledgeTaskSnapshot['status']
message?: string
}): KnowledgeTaskSnapshot {
while (this.tasks.size >= maximumKnowledgeTasks) {
const oldestTaskId = this.tasks.keys().next().value as
| string
| undefined
if (!oldestTaskId) {
break
}
this.tasks.delete(oldestTaskId)
}
const now = new Date().toISOString()
const status = input.status ?? 'queued'
const task: KnowledgeTaskSnapshot = {
id: randomUUID(),
libraryId: input.libraryId,
sourceId: input.sourceId,
documentId: input.documentId,
documentName: input.documentName.slice(0, 512),
kind: input.kind,
status,
progress: status === 'succeeded' || status === 'skipped' ? 100 : 0,
message: input.message?.slice(0, 1_000),
createdAt: now,
startedAt: status === 'running' ? now : undefined,
completedAt:
status === 'succeeded' ||
status === 'failed' ||
status === 'skipped'
? now
: undefined
}
this.tasks.set(task.id, task)
return task
}
private updateKnowledgeTask(
taskId: string,
update: {
status?: KnowledgeTaskSnapshot['status']
progress?: number
message?: string
documentId?: string
documentName?: string
}
): void {
const current = this.tasks.get(taskId)
if (!current) {
return
}
const status = update.status ?? current.status
const terminal =
status === 'succeeded' ||
status === 'failed' ||
status === 'skipped'
this.tasks.set(taskId, {
...current,
status,
documentId: update.documentId ?? current.documentId,
documentName:
update.documentName?.slice(0, 512) ?? current.documentName,
progress:
status === 'succeeded' || status === 'skipped'
? 100
: update.progress === undefined
? current.progress
: Math.max(0, Math.min(100, Math.round(update.progress))),
message:
update.message === undefined
? current.message
: update.message.slice(0, 1_000),
startedAt:
status === 'running' && !current.startedAt
? new Date().toISOString()
: current.startedAt,
completedAt:
terminal && !current.completedAt
? new Date().toISOString()
: current.completedAt
})
}
private failKnowledgeTask(taskId: string, error: unknown): void {
const current = this.tasks.get(taskId)
if (
current?.status === 'succeeded' ||
current?.status === 'skipped'
) {
return
}
this.updateKnowledgeTask(taskId, {
status: 'failed',
message: error instanceof Error ? error.message : '任务失败'
})
}
createLibrary(input: CreateKnowledgeBaseInput): KnowledgeBase {
return this.database.createKnowledgeBase(input)
}
@@ -175,6 +297,11 @@ export class KnowledgeService {
for (const source of this.database.listSources(id)) {
this.stopWatcher(source.id)
}
for (const task of this.tasks.values()) {
if (task.libraryId === id) {
this.tasks.delete(task.id)
}
}
const deleted = this.database.deleteKnowledgeBase(id)
if (deleted && library.storageMode === 'managed') {
const path = join(this.managedRoot, id)
@@ -206,7 +333,8 @@ export class KnowledgeService {
documents: [],
entities: [],
relations: [],
evidence: []
evidence: [],
tasks: []
}
}
const sources = this.database.listSources(libraryId).map((source) => ({
@@ -253,7 +381,12 @@ export class KnowledgeService {
documents,
entities: this.database.listEntities(libraryId),
relations: this.database.listRelations(libraryId),
evidence: this.database.listEvidence(libraryId)
evidence: this.database.listEvidence(libraryId),
tasks: [...this.tasks.values()]
.filter((task) => task.libraryId === libraryId)
.sort((left, right) =>
right.createdAt.localeCompare(left.createdAt)
)
}
}
@@ -427,7 +560,28 @@ export class KnowledgeService {
this.lifecycleController.signal,
AbortSignal.timeout(60_000)
])
const result = await this.urlImporter.import(input, effectiveSignal)
const parsingTask = this.createKnowledgeTask({
libraryId: library.id,
sourceId,
documentName: new URL(input).hostname,
kind: 'parsing'
})
let result: Awaited<ReturnType<UrlImporter['import']>>
try {
this.updateKnowledgeTask(parsingTask.id, {
status: 'running',
progress: 10,
message: '正在抓取并解析网页'
})
result = await this.urlImporter.import(input, effectiveSignal)
this.updateKnowledgeTask(parsingTask.id, {
progress: 70,
message: '正在保存网页内容'
})
} catch (error) {
this.failKnowledgeTask(parsingTask.id, error)
throw error
}
let source = this.database.upsertSource({
id: sourceId,
knowledgeBaseId,
@@ -465,6 +619,12 @@ export class KnowledgeService {
location: chunk.locator
}))
)
this.updateKnowledgeTask(parsingTask.id, {
status: 'succeeded',
documentId: document.id,
documentName: document.title,
message: '网页解析完成'
})
await this.indexDocumentEmbeddings(document)
await this.extractGraph(effectiveLibrary, document)
source = this.database.upsertSource({
@@ -477,6 +637,7 @@ export class KnowledgeService {
}
})
} catch (error) {
this.failKnowledgeTask(parsingTask.id, error)
this.database.upsertSource({
...source,
status: 'error',
@@ -511,6 +672,62 @@ export class KnowledgeService {
return this.syncSource(sourceId)
}
async reextractGraph(knowledgeBaseId: string): Promise<void> {
const library = this.requireLibrary(knowledgeBaseId)
if (!library.graphEnabled) {
throw new Error('请先启用知识图谱')
}
if (library.graphStrategy === 'ask') {
throw new Error('按需询问策略不会自动抽取,请在设置中选择其他策略')
}
const documents = this.database.listDocuments(library.id)
const tasks = documents.map((document) =>
this.createKnowledgeTask({
libraryId: library.id,
sourceId: document.sourceId,
documentId: document.id,
documentName: document.title,
kind: 'graph',
message: '等待重新抽取'
})
)
for (let index = 0; index < documents.length; index += 1) {
const document = documents[index]
const task = tasks[index]
if (!document || !task) {
continue
}
try {
this.updateKnowledgeTask(task.id, {
status: 'running',
progress: 10,
message: '正在重新抽取知识图谱'
})
const result = await this.extractGraphResult(library, document)
this.updateKnowledgeTask(task.id, {
progress: 85,
message: '正在保存实体和关系'
})
this.database.removeEvidenceForDocument(document.id)
this.storeExtractedGraph(library, document, result)
this.updateKnowledgeTask(task.id, {
status: 'succeeded',
message: `已抽取 ${result.entities.length} 个实体、${result.relations.length} 条关系`
})
} catch (error) {
this.failKnowledgeTask(task.id, error)
for (const pendingTask of tasks.slice(index + 1)) {
this.updateKnowledgeTask(pendingTask.id, {
status: 'skipped',
message: '因前序图谱任务失败而未执行'
})
}
throw error
}
}
this.database.pruneUnreferencedGeneratedGraph(library.id)
}
async removeSource(sourceId: string): Promise<boolean> {
const source = this.requireSource(sourceId)
const library = this.requireLibrary(source.knowledgeBaseId)
@@ -594,19 +811,42 @@ export class KnowledgeService {
if (!file) {
continue
}
const parsingTask = this.createKnowledgeTask({
libraryId: library.id,
sourceId: source.id,
documentName: file.relativePath,
kind: 'parsing'
})
try {
this.updateKnowledgeTask(parsingTask.id, {
status: 'running',
progress: 10,
message: '正在读取文档'
})
const buffer = await this.readBoundedFile(file.absolutePath)
this.updateKnowledgeTask(parsingTask.id, {
progress: 35,
message: '正在解析文档内容'
})
const checksum = createHash('sha256').update(buffer).digest('hex')
const previous = existing.find(
(document) => document.externalId === file.relativePath
)
if (previous?.checksum === checksum) {
this.updateKnowledgeTask(parsingTask.id, {
status: 'skipped',
message: '文档内容未发生变化'
})
continue
}
const parsed = await parseDocument(
basename(file.absolutePath),
buffer
)
this.updateKnowledgeTask(parsingTask.id, {
progress: 75,
message: '正在保存解析结果'
})
const document = this.database.upsertDocument(
{
knowledgeBaseId: library.id,
@@ -630,10 +870,17 @@ export class KnowledgeService {
location: chunk.locator
}))
)
this.updateKnowledgeTask(parsingTask.id, {
status: 'succeeded',
documentId: document.id,
documentName: document.title,
message: '文档解析完成'
})
this.database.removeEvidenceForDocument(document.id)
await this.indexDocumentEmbeddings(document)
await this.extractGraph(library, document)
} catch (error) {
this.failKnowledgeTask(parsingTask.id, error)
failures.push(
`${file.relativePath}: ${
error instanceof Error ? error.message : '解析失败'
@@ -661,10 +908,26 @@ export class KnowledgeService {
requestedProvider?: EmbeddingProvider
): Promise<void> {
const provider = requestedProvider ?? this.embeddingProvider
const task = this.createKnowledgeTask({
libraryId: document.knowledgeBaseId,
sourceId: document.sourceId,
documentId: document.id,
documentName: document.title,
kind: 'embedding'
})
if (!provider) {
this.updateKnowledgeTask(task.id, {
status: 'skipped',
message: '未启用向量化'
})
return
}
try {
this.updateKnowledgeTask(task.id, {
status: 'running',
progress: 5,
message: '正在准备文档分块'
})
const chunks = this.database.listChunks(document.id, 10_000)
const embeddings: Array<{
chunkId: string
@@ -704,8 +967,21 @@ export class KnowledgeService {
vector
})
}
this.updateKnowledgeTask(task.id, {
progress:
5 +
((offset + batch.length) / Math.max(chunks.length, 1)) * 85,
message: `正在向量化 ${Math.min(
offset + batch.length,
chunks.length
)}/${chunks.length} 个分块`
})
}
if (this.embeddingProvider !== provider) {
this.updateKnowledgeTask(task.id, {
status: 'skipped',
message: '向量模型配置已变化'
})
return
}
this.database.replaceDocumentEmbeddings(
@@ -714,11 +990,17 @@ export class KnowledgeService {
provider.model,
embeddings
)
this.updateKnowledgeTask(task.id, {
status: 'succeeded',
message: `已向量化 ${chunks.length} 个分块`
})
} catch (error) {
if (this.lifecycleController.signal.aborted) {
this.failKnowledgeTask(task.id, new Error('向量化已取消'))
return
}
const safeError = classifyEmbeddingError(error)
this.failKnowledgeTask(task.id, safeError)
try {
this.database.recordEmbeddingIndexError(
document.id,
@@ -736,11 +1018,55 @@ export class KnowledgeService {
library: KnowledgeBase,
document: Document
): Promise<void> {
if (!library.graphEnabled || library.graphStrategy === 'ask') {
const task = this.createKnowledgeTask({
libraryId: library.id,
sourceId: document.sourceId,
documentId: document.id,
documentName: document.title,
kind: 'graph'
})
if (!library.graphEnabled) {
this.updateKnowledgeTask(task.id, {
status: 'skipped',
message: '知识图谱未启用'
})
return
}
if (library.graphStrategy === 'ask') {
this.updateKnowledgeTask(task.id, {
status: 'skipped',
message: '按需询问策略不自动抽取'
})
return
}
try {
this.updateKnowledgeTask(task.id, {
status: 'running',
progress: 10,
message: '正在准备图谱抽取'
})
const result = await this.extractGraphResult(library, document)
this.updateKnowledgeTask(task.id, {
progress: 85,
message: '正在保存实体和关系'
})
this.storeExtractedGraph(library, document, result)
this.updateKnowledgeTask(task.id, {
status: 'succeeded',
message: `已抽取 ${result.entities.length} 个实体、${result.relations.length} 条关系`
})
} catch (error) {
this.failKnowledgeTask(task.id, error)
throw error
}
}
private async extractGraphResult(
library: KnowledgeBase,
document: Document
): Promise<GraphExtractionResult> {
const chunks = this.database.listChunks(document.id)
const result = await extractKnowledgeGraph(
return extractKnowledgeGraph(
chunks.map((chunk) => ({
id: chunk.id,
content: chunk.content
@@ -750,6 +1076,13 @@ export class KnowledgeService {
extractStructured: this.extractStructured
}
)
}
private storeExtractedGraph(
library: KnowledgeBase,
document: Document,
result: GraphExtractionResult
): void {
const existingEntities = this.database.listEntities(library.id)
const entityIds = new Map<string, string>()
for (const entity of result.entities) {
+22 -1
View File
@@ -65,7 +65,12 @@ describe('createModelGraphExtractor', () => {
choices: [
{
message: {
content: '```json\n{"relations":[]}\n```'
content: [
{
type: 'text',
text: '```json\n{"relations":[]}\n```'
}
]
}
}
]
@@ -136,6 +141,22 @@ describe('createModelGraphExtractor', () => {
)
})
it('accepts top-level output text from compatible Responses providers', async () => {
const extract = createModelGraphExtractor(
store({ modelProtocol: 'openai-responses' }),
vi.fn(async () =>
jsonResponse({
output_text: '{"entities":[],"relations":[]}'
})
)
)
await expect(extract('extract this')).resolves.toEqual({
entities: [],
relations: []
})
})
it('requires a key only for API-key authentication', async () => {
const extract = createModelGraphExtractor(
store({
+23 -5
View File
@@ -95,12 +95,28 @@ function openAIChatText(payload: unknown): string {
if (!Array.isArray(choices)) {
return ''
}
const message = record(record(choices[0])?.message)
return typeof message?.content === 'string' ? message.content : ''
const choice = record(choices[0])
const message = record(choice?.message)
if (typeof message?.content === 'string') {
return message.content
}
if (Array.isArray(message?.content)) {
return message.content
.flatMap((part) => {
const value = record(part)
return typeof value?.text === 'string' ? [value.text] : []
})
.join('')
}
return typeof choice?.text === 'string' ? choice.text : ''
}
function openAIResponsesText(payload: unknown): string {
const output = record(payload)?.output
const response = record(payload)
if (typeof response?.output_text === 'string') {
return response.output_text
}
const output = response?.output
if (!Array.isArray(output)) {
return ''
}
@@ -111,7 +127,7 @@ function openAIResponsesText(payload: unknown): string {
})
.flatMap((part) => {
const value = record(part)
return value?.type === 'output_text' &&
return (value?.type === 'output_text' || value?.type === 'text') &&
typeof value.text === 'string'
? [value.text]
: []
@@ -211,7 +227,9 @@ export function createModelGraphExtractor(
? openAIResponsesText(payload)
: openAIChatText(payload)
if (!text) {
throw new Error('模型未返回图谱内容')
throw new Error(
'模型未返回图谱内容,请重试或在知识库设置中切换到规则抽取'
)
}
return extractJsonText(text)
}
+5
View File
@@ -805,6 +805,11 @@ const desktopApi: DesktopApi = {
libraryId
)
},
reextractGraph: (libraryId) =>
ipcRenderer.invoke(
ipcChannels.knowledgeReextractGraph,
libraryId
) as Promise<void>,
selectFiles: async (libraryId, graphStrategy) => {
await ipcRenderer.invoke(ipcChannels.knowledgeSelectFiles, {
libraryId,
+153 -48
View File
@@ -498,6 +498,7 @@ const api: DesktopApi = {
})),
updateLibrary: vi.fn(async () => {}),
deleteLibrary: vi.fn(async () => {}),
reextractGraph: vi.fn(async () => {}),
selectFiles: vi.fn(async () => {}),
selectDirectory: vi.fn(async () => {}),
importDroppedFiles: vi.fn(async () => {}),
@@ -518,6 +519,35 @@ const api: DesktopApi = {
}
}
function composerMenuTrigger(
label: '专家角色' | '工作模式'
): HTMLButtonElement {
return screen.getByRole('button', {
name: new RegExp(`^${label}`, 'u')
})
}
function openComposerMenu(
label: '专家角色' | '工作模式'
): HTMLElement {
fireEvent.click(composerMenuTrigger(label))
return screen.getByRole('menu', { name: label })
}
function selectComposerOption(
label: '专家角色' | '工作模式',
optionLabel: string
): void {
const menu = openComposerMenu(label)
const option = within(menu)
.getByText(optionLabel, { selector: 'span' })
.closest<HTMLButtonElement>('button')
if (!option) {
throw new Error(`Missing ${label} option: ${optionLabel}`)
}
fireEvent.click(option)
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
@@ -788,26 +818,28 @@ describe('App', () => {
return
}
expect(within(topbar).queryByLabelText('专家角色')).not.toBeInTheDocument()
expect(screen.getByLabelText('专家角色').closest('.composer')).not.toBeNull()
expect(
within(topbar).queryByRole('button', {
name: /^/u
})
).not.toBeInTheDocument()
expect(composerMenuTrigger('专家角色').closest('.composer')).not.toBeNull()
const appMenuTrigger = within(topbar).getByLabelText('应用菜单')
fireEvent.click(appMenuTrigger)
const themeToggle = within(topbar).getByRole('button', {
name: '切换深色主题'
})
fireEvent.click(themeToggle)
await waitFor(() =>
expect(document.documentElement.dataset.theme).toBe('dark')
)
expect(
screen.queryByRole('menuitem', { name: '重命名会话' })
).not.toBeInTheDocument()
expect(
screen.getByRole('menuitem', { name: '安全与 Runtime 设置' })
).toBeVisible()
await waitFor(() =>
expect(
screen.getByRole('menuitem', { name: '安全与 Runtime 设置' })
).toHaveFocus()
)
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(screen.getByRole('menuitem', { name: '使用帮助' })).toHaveFocus()
fireEvent.keyDown(document, { key: 'Escape' })
expect(appMenuTrigger).toHaveFocus()
within(topbar).getByRole('button', {
name: '切换浅色主题'
})
).toBe(themeToggle)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
const conversationMenuTrigger = within(
@@ -1831,7 +1863,9 @@ describe('App', () => {
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(screen.queryByLabelText('专家角色')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^/u })
).not.toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
@@ -1884,11 +1918,14 @@ describe('App', () => {
it('offers only Ask and Execute in visible work mode controls', async () => {
render(<App />)
const mode = await screen.findByLabelText('工作模式')
await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
const modeMenu = openComposerMenu('工作模式')
expect(
within(mode)
.getAllByRole('option')
.map((option) => option.textContent)
within(modeMenu)
.getAllByRole('menuitemradio')
.map((option) => option.querySelector('span')?.textContent)
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
fireEvent.click(screen.getByLabelText('新建项目'))
@@ -1904,6 +1941,46 @@ describe('App', () => {
expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull()
})
it('matches expert and work mode keyboard menus to the model picker', async () => {
render(<App />)
const expertTrigger = await screen.findByRole('button', {
name: '专家角色:通用助手'
})
expect(expertTrigger).toHaveClass('model-button')
fireEvent.keyDown(expertTrigger, { key: 'ArrowDown' })
const expertMenu = screen.getByRole('menu', {
name: '专家角色'
})
expect(expertMenu).toHaveClass('runtime-picker__menu')
const generalExpert = within(expertMenu).getByRole(
'menuitemradio',
{ name: /^/u }
)
const expertTeam = within(expertMenu).getByRole(
'menuitemradio',
{ name: /^/u }
)
await waitFor(() => expect(generalExpert).toHaveFocus())
fireEvent.keyDown(generalExpert, { key: 'ArrowDown' })
expect(expertTeam).toHaveFocus()
fireEvent.keyDown(expertTeam, { key: 'Escape' })
expect(expertTrigger).toHaveFocus()
expect(
screen.queryByRole('menu', { name: '专家角色' })
).not.toBeInTheDocument()
const modeTrigger = composerMenuTrigger('工作模式')
fireEvent.click(modeTrigger)
const modeMenu = screen.getByRole('menu', { name: '工作模式' })
expect(modeMenu).toHaveClass('runtime-picker__menu')
fireEvent.pointerDown(screen.getByLabelText('向 GoodBuddy 提问'))
expect(
screen.queryByRole('menu', { name: '工作模式' })
).not.toBeInTheDocument()
})
it('groups composer tools and exposes clear control descriptions', async () => {
render(<App />)
@@ -1930,11 +2007,19 @@ describe('App', () => {
{ name: '对话设置' }
)
expect(
within(conversationSettings).getByLabelText('专家角色')
within(conversationSettings).getByRole('button', {
name: '专家角色:通用助手'
})
).toBeInTheDocument()
expect(
within(conversationSettings).getByLabelText('工作模式')
within(conversationSettings).getByRole('button', {
name: '工作模式:Ask · 只读问答'
})
).toBeInTheDocument()
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送'
)
expect(
within(conversationSettings).getByRole('button', {
name: //u
@@ -1954,8 +2039,10 @@ describe('App', () => {
])
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(mode).toHaveValue('ask')
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
expect(mode).toBeEnabled()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '制定发布方案' }
})
@@ -1993,7 +2080,11 @@ describe('App', () => {
expect(await screen.findByLabelText('当前项目')).toHaveValue(
secondProject.id
)
expect(screen.getByLabelText('工作模式')).toHaveValue('execute')
expect(
screen.getByRole('button', {
name: '工作模式:Execute · 受控执行'
})
).toBeEnabled()
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: project.id }
@@ -2151,8 +2242,9 @@ describe('App', () => {
})
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(mode).toHaveValue('ask')
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
expect(mode).toBeEnabled()
expect(mode.closest('.composer')).not.toBeNull()
expect(
@@ -2160,7 +2252,10 @@ describe('App', () => {
new RegExp(`${label} Ask 模式.*只允许搜索当前启用的知识库`)
)
).toBeInTheDocument()
fireEvent.change(mode, { target: { value: 'execute' } })
selectComposerOption('工作模式', 'Execute · 受控执行')
expect(mode).toHaveAccessibleName(
'工作模式:Execute · 受控执行'
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '执行任务' }
@@ -2203,11 +2298,14 @@ describe('App', () => {
})
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(mode).toHaveValue('ask')
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
expect(mode).toBeEnabled()
fireEvent.change(mode, { target: { value: 'execute' } })
expect(mode).toHaveValue('execute')
selectComposerOption('工作模式', 'Execute · 受控执行')
expect(mode).toHaveAccessibleName(
'工作模式:Execute · 受控执行'
)
fireEvent.click(await screen.findByRole('button', { name: /OpenCode/u }))
fireEvent.click(
@@ -2217,7 +2315,7 @@ describe('App', () => {
)
await waitFor(() => {
expect(mode).toHaveValue('ask')
expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答')
expect(mode).toBeEnabled()
})
})
@@ -2232,13 +2330,16 @@ describe('App', () => {
})
render(<App />)
const mode = await screen.findByLabelText('工作模式')
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
const modeMenu = openComposerMenu('工作模式')
expect(
within(mode).getByRole('option', {
name: 'Execute · 受控执行'
within(modeMenu).getByRole('menuitemradio', {
name: /^Execute · /u
})
).toBeDisabled()
expect(mode).toHaveValue('ask')
expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答')
})
it('allows a direct model to submit Execute with GoodBuddy approvals', async () => {
@@ -2251,8 +2352,10 @@ describe('App', () => {
})
render(<App />)
const mode = await screen.findByLabelText('工作模式')
fireEvent.change(mode, { target: { value: 'execute' } })
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
selectComposerOption('工作模式', 'Execute · 受控执行')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '读取项目文件' }
})
@@ -3116,7 +3219,7 @@ describe('App', () => {
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'描述你想生成的图片…'
'描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送'
)
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenCalled()
@@ -3195,9 +3298,7 @@ describe('App', () => {
it('can dispatch a request to the parallel expert team', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('专家角色'), {
target: { value: 'team' }
})
selectComposerOption('专家角色', '专家团队(并行)')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '制定发布计划' }
})
@@ -3260,10 +3361,14 @@ describe('App', () => {
])
render(<App />)
await screen.findByRole('option', { name: '发布专家' })
fireEvent.change(screen.getByLabelText('专家角色'), {
target: { value: expertId }
})
await waitFor(() => expect(api.experts.list).toHaveBeenCalled())
const expertMenu = openComposerMenu('专家角色')
fireEvent.click(
(await within(expertMenu).findByText('发布专家', {
selector: 'span'
}))
.closest<HTMLButtonElement>('button')!
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '检查发布方案' }
})
@@ -4021,7 +4126,7 @@ describe('App', () => {
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
expect(
screen.queryByLabelText('专家角色')
screen.queryByRole('button', { name: /^/u })
).not.toBeInTheDocument()
})
+355 -179
View File
@@ -19,6 +19,7 @@ import {
Mic,
Minimize2,
Minus,
Moon,
MoreHorizontal,
Paperclip,
PanelLeft,
@@ -31,6 +32,7 @@ import {
PanelsTopLeft,
Sparkles,
Square,
Sun,
TerminalSquare,
Trash2,
UserRound,
@@ -42,7 +44,8 @@ import {
useMemo,
useReducer,
useRef,
useState
useState,
type ReactNode
} from 'react'
import type {
ApprovalDecision,
@@ -1032,6 +1035,186 @@ function WindowControls({
)
}
type ComposerMenuOption<T extends string> = {
value: T
label: string
description: string
disabled?: boolean
}
function ComposerMenuSelect<T extends string>({
ariaLabel,
className,
describedBy,
disabled = false,
icon,
menuOpen,
onChange,
onOpenChange,
options,
value
}: {
ariaLabel: string
className: string
describedBy?: string
disabled?: boolean
icon: ReactNode
menuOpen: boolean
onChange: (value: T) => void
onOpenChange: (open: boolean) => void
options: readonly ComposerMenuOption<T>[]
value: T
}): React.JSX.Element {
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const selectedOption =
options.find((option) => option.value === value) ?? options[0]
useEffect(() => {
if (!menuOpen) {
return
}
const menu = menuRef.current
if (!menu) {
return
}
const menuItems = Array.from(
menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
).filter((item) => !item.disabled)
const initialItem =
menuItems.find(
(item) => item.getAttribute('aria-checked') === 'true'
) ?? menuItems[0]
menuItems.forEach((item) => {
item.tabIndex = item === initialItem ? 0 : -1
})
const focusFrame = requestAnimationFrame(() => {
initialItem?.focus()
})
const isMenuTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(menu.contains(target) ||
buttonRef.current?.contains(target) === true)
const dismissOnOutsidePointer = (event: PointerEvent): void => {
if (!isMenuTarget(event.target)) {
onOpenChange(false)
}
}
const dismissOnOutsideFocus = (event: FocusEvent): void => {
if (!isMenuTarget(event.target)) {
onOpenChange(false)
}
}
document.addEventListener('pointerdown', dismissOnOutsidePointer)
document.addEventListener('focusin', dismissOnOutsideFocus)
return () => {
cancelAnimationFrame(focusFrame)
document.removeEventListener(
'pointerdown',
dismissOnOutsidePointer
)
document.removeEventListener('focusin', dismissOnOutsideFocus)
}
}, [menuOpen, onOpenChange, value])
return (
<div className={`runtime-picker composer-picker ${className}`}>
<button
aria-describedby={describedBy}
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={`${ariaLabel}${selectedOption?.label ?? ''}`}
className="model-button composer-picker__button"
disabled={disabled}
onClick={() => onOpenChange(!menuOpen)}
onKeyDown={(event) => {
if (
!menuOpen &&
(event.key === 'ArrowDown' ||
event.key === 'Enter' ||
event.key === ' ')
) {
event.preventDefault()
onOpenChange(true)
}
}}
ref={buttonRef}
title={`${ariaLabel}${selectedOption?.label ?? ''}`}
type="button"
>
{icon}
<span className="model-button__label">
{selectedOption?.label}
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
{menuOpen && (
<div
aria-label={ariaLabel}
className="runtime-picker__menu composer-picker__menu"
onKeyDown={(event) => {
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"]'
)
).filter((item) => !item.disabled)
const currentIndex = items.indexOf(
document.activeElement as HTMLButtonElement
)
let nextIndex: number | undefined
if (event.key === 'ArrowDown') {
nextIndex = (currentIndex + 1) % items.length
} else if (event.key === 'ArrowUp') {
nextIndex =
(currentIndex - 1 + items.length) % items.length
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = items.length - 1
} else if (event.key === 'Escape') {
event.preventDefault()
onOpenChange(false)
buttonRef.current?.focus()
}
const nextItem =
nextIndex === undefined ? undefined : items.at(nextIndex)
if (nextItem) {
event.preventDefault()
items.forEach((item) => {
item.tabIndex = item === nextItem ? 0 : -1
})
nextItem.focus()
}
}}
ref={menuRef}
role="menu"
>
{options.map((option) => (
<button
aria-checked={option.value === value}
disabled={option.disabled}
key={option.value}
onClick={() => {
onChange(option.value)
onOpenChange(false)
requestAnimationFrame(() => {
buttonRef.current?.focus()
})
}}
role="menuitemradio"
tabIndex={option.value === value ? 0 : -1}
type="button"
>
<span>{option.label}</span>
<small>{option.description}</small>
</button>
))}
</div>
)}
</div>
)
}
function App(): React.JSX.Element {
const [conversations, setConversations] = useState(loadConversations)
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
@@ -1105,9 +1288,11 @@ function App(): React.JSX.Element {
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
const [composerMenuOpen, setComposerMenuOpen] = useState<
'expert' | 'mode' | undefined
>()
const runtimeMenuButtonRef = useRef<HTMLButtonElement>(null)
const runtimeMenuRef = useRef<HTMLDivElement>(null)
const [topbarMenuOpen, setTopbarMenuOpen] = useState(false)
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
const [appearanceTheme, setAppearanceTheme] =
useState<AppearanceTheme>(loadAppearanceTheme)
@@ -1120,12 +1305,67 @@ function App(): React.JSX.Element {
appearanceTheme,
systemPrefersDark
)
const toggleAppearanceTheme = useCallback((): void => {
setAppearanceTheme(
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
)
}, [resolvedAppearanceTheme])
const agentRuntimeSelected = isAgentRuntime(runtime)
const effectiveWorkMode =
workMode === 'execute' &&
runtime?.supportsToolExecution === false
? 'ask'
: workMode
const setExpertMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? 'expert' : undefined)
if (open) {
setRuntimeMenuOpen(false)
}
}, [])
const setModeMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? 'mode' : undefined)
if (open) {
setRuntimeMenuOpen(false)
}
}, [])
const assistantExpertOptions = useMemo<
ComposerMenuOption<string>[]
>(
() => [
{
value: '',
label: '通用助手',
description: '默认单助手'
},
{
value: 'team',
label: '专家团队(并行)',
description: '多个专家并行协作'
},
...assistantExperts.map((expert) => ({
value: expert.id,
label: expert.name,
description: expert.description || '自定义专家角色'
}))
],
[assistantExperts]
)
const workModeOptions = useMemo<
ComposerMenuOption<InteractiveWorkMode>[]
>(
() =>
interactiveWorkModes.map((value) => ({
value,
label: workModeLabels[value],
description:
value === 'execute'
? '通过审批后执行工具操作'
: '只读问答,不修改文件',
disabled:
value === 'execute' && !runtime?.supportsToolExecution
})),
[runtime?.supportsToolExecution]
)
const [appInfo, setAppInfo] = useState<AppInfo>()
const [narrowWindow, setNarrowWindow] = useState(
() => window.innerWidth < 900
@@ -1193,10 +1433,12 @@ function App(): React.JSX.Element {
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
evidence: [],
tasks: []
})
const [knowledgeLoading, setKnowledgeLoading] = useState(true)
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0)
const knowledgeLoadRequestRef = useRef(0)
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
undefined
@@ -1216,8 +1458,6 @@ function App(): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const topbarMenuRef = useRef<HTMLDivElement>(null)
const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>()
)
@@ -1279,66 +1519,6 @@ function App(): React.JSX.Element {
resizeComposerTextarea(inputRef.current)
}, [input])
useEffect(() => {
if (!topbarMenuOpen) {
return
}
const focusFrame = requestAnimationFrame(() => {
topbarMenuRef.current
?.querySelector<HTMLButtonElement>('[role="menuitem"]')
?.focus()
})
const closeOnOutsidePointer = (event: PointerEvent): void => {
if (
event.target instanceof Node &&
!topbarMenuRef.current?.contains(event.target)
) {
setTopbarMenuOpen(false)
}
}
const handleMenuKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
setTopbarMenuOpen(false)
topbarMenuTriggerRef.current?.focus()
return
}
const menuItems = Array.from(
topbarMenuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]'
) ?? []
)
if (menuItems.length === 0) {
return
}
const currentIndex = menuItems.indexOf(
document.activeElement as HTMLButtonElement
)
const targetIndex =
event.key === 'Home'
? 0
: event.key === 'End'
? menuItems.length - 1
: event.key === 'ArrowDown'
? (currentIndex + 1) % menuItems.length
: event.key === 'ArrowUp'
? (currentIndex - 1 + menuItems.length) %
menuItems.length
: -1
if (targetIndex >= 0) {
event.preventDefault()
menuItems[targetIndex]?.focus()
}
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
document.addEventListener('keydown', handleMenuKeyDown)
return () => {
cancelAnimationFrame(focusFrame)
document.removeEventListener('pointerdown', closeOnOutsidePointer)
document.removeEventListener('keydown', handleMenuKeyDown)
}
}, [topbarMenuOpen])
useEffect(() => {
saveAppearanceTheme(appearanceTheme)
}, [appearanceTheme])
@@ -2999,6 +3179,25 @@ function App(): React.JSX.Element {
return () => clearTimeout(timeout)
}, [refreshKnowledge])
useEffect(() => {
if (view !== 'knowledge' && knowledgeOperationCount === 0) {
return
}
const interval = setInterval(() => {
void refreshKnowledge(
knowledgeSnapshot.selectedLibraryId
).catch(() => {
// The task center keeps the last successful snapshot while polling.
})
}, knowledgeOperationCount > 0 ? 350 : 1_000)
return () => clearInterval(interval)
}, [
knowledgeOperationCount,
knowledgeSnapshot.selectedLibraryId,
refreshKnowledge,
view
])
useEffect(() => {
void Promise.all([
window.goodbuddy.settings.getRuntime(),
@@ -4078,11 +4277,20 @@ function App(): React.JSX.Element {
await refreshKnowledge()
}
const runKnowledgeSourceAction = async (
action: () => Promise<void>
): Promise<void> => {
await action()
await refreshSelectedKnowledge()
const runKnowledgeSourceAction = async <T,>(
action: () => Promise<T>
): Promise<T> => {
setKnowledgeOperationCount((count) => count + 1)
try {
const result = await action()
await refreshSelectedKnowledge()
return result
} catch (error) {
await refreshSelectedKnowledge().catch(() => undefined)
throw error
} finally {
setKnowledgeOperationCount((count) => Math.max(0, count - 1))
}
}
const openActivityConversation = (conversationId: string): void => {
@@ -4627,55 +4835,28 @@ function App(): React.JSX.Element {
<PanelRightOpen size={18} />
</button>
)}
<div className="topbar-menu" ref={topbarMenuRef}>
<button
aria-expanded={topbarMenuOpen}
aria-haspopup="menu"
aria-label="应用菜单"
className="icon-button"
onClick={() =>
setTopbarMenuOpen((current) => !current)
}
ref={topbarMenuTriggerRef}
type="button"
>
<MoreHorizontal size={18} />
</button>
{topbarMenuOpen && (
<div
aria-label="应用操作"
className="topbar-menu__popover"
role="menu"
>
<button
onClick={() => {
setTopbarMenuOpen(false)
setView('settings')
}}
role="menuitem"
type="button"
>
<ShieldCheck size={16} />
Runtime
</button>
<button
onClick={() => {
setTopbarMenuOpen(false)
notify({
tone: 'info',
message:
'输入问题后按 Enter 发送,Shift+Enter 换行。附件只会在你明确选择后发送。'
})
}}
role="menuitem"
type="button"
>
<CircleHelp size={16} />
使
</button>
</div>
<button
aria-label={
resolvedAppearanceTheme === 'dark'
? '切换浅色主题'
: '切换深色主题'
}
aria-pressed={resolvedAppearanceTheme === 'dark'}
className="icon-button theme-toggle-button"
onClick={toggleAppearanceTheme}
title={
resolvedAppearanceTheme === 'dark'
? '切换浅色主题'
: '切换深色主题'
}
type="button"
>
{resolvedAppearanceTheme === 'dark' ? (
<Sun aria-hidden="true" size={18} />
) : (
<Moon aria-hidden="true" size={18} />
)}
</div>
</button>
</div>
<WindowControls
onError={handleWindowControlError}
@@ -5260,11 +5441,11 @@ function App(): React.JSX.Element {
<div className="composer__input">
<textarea
aria-label="向 GoodBuddy 提问"
placeholder={
placeholder={`${
runtime?.capability === 'image-generation'
? '描述你想生成的图片…'
: '给 GoodBuddy 发消息…'
}
}\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送`}
ref={inputRef}
rows={3}
value={input}
@@ -5418,68 +5599,46 @@ function App(): React.JSX.Element {
className="composer__configuration"
role="group"
>
<label
className="composer__expert"
title="选择参与本次对话的专家角色"
>
<Bot aria-hidden="true" size={15} />
<select
aria-label="专家角色"
disabled={runtime?.capability === 'image-generation'}
onChange={(event) =>
setSelectedExpertId(event.target.value)
}
value={selectedExpertId}
>
<option value=""></option>
<option value="team"></option>
{assistantExperts.map((expert) => (
<option key={expert.id} value={expert.id}>
{expert.name}
</option>
))}
</select>
</label>
<label
className={`composer__mode composer__mode--${effectiveWorkMode}`}
title={`工作模式:${workModeLabels[effectiveWorkMode]}`}
>
{effectiveWorkMode === 'execute' ? (
<ShieldCheck aria-hidden="true" size={15} />
) : (
<CircleHelp aria-hidden="true" size={15} />
)}
<select
aria-describedby="work-mode-hint"
aria-label="工作模式"
onChange={(event) =>
setWorkMode(
event.target.value as InteractiveWorkMode
)
}
value={effectiveWorkMode}
>
{interactiveWorkModes.map((value) => (
<option
disabled={
value === 'execute' &&
!runtime?.supportsToolExecution
}
key={value}
value={value}
>
{workModeLabels[value]}
</option>
))}
</select>
</label>
<ComposerMenuSelect
ariaLabel="专家角色"
className="composer-picker--expert"
disabled={
runtime?.capability === 'image-generation'
}
icon={<Bot aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === 'expert'}
onChange={setSelectedExpertId}
onOpenChange={setExpertMenuOpen}
options={assistantExpertOptions}
value={selectedExpertId}
/>
<ComposerMenuSelect
ariaLabel="工作模式"
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
describedBy="work-mode-hint"
icon={
effectiveWorkMode === 'execute' ? (
<ShieldCheck aria-hidden="true" size={15} />
) : (
<CircleHelp aria-hidden="true" size={15} />
)
}
menuOpen={composerMenuOpen === 'mode'}
onChange={setWorkMode}
onOpenChange={setModeMenuOpen}
options={workModeOptions}
value={effectiveWorkMode}
/>
<div className="runtime-picker">
<button
aria-expanded={runtimeMenuOpen}
aria-haspopup="menu"
className="model-button"
disabled={isRunning || runtimeSwitching}
onClick={() => setRuntimeMenuOpen(!runtimeMenuOpen)}
onClick={() => {
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(!runtimeMenuOpen)
}}
onKeyDown={(event) => {
if (
!runtimeMenuOpen &&
@@ -5488,6 +5647,7 @@ function App(): React.JSX.Element {
event.key === ' ')
) {
event.preventDefault()
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(true)
}
}}
@@ -5782,14 +5942,29 @@ function App(): React.JSX.Element {
)
}
onDeleteLibrary={deleteKnowledgeLibrary}
onUpdateLibrary={(libraryId, update) =>
runKnowledgeSourceAction(async () => {
onReextractGraph={async (libraryId) => {
await runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.reextractGraph(libraryId)
)
notify({
tone: 'success',
message: '知识图谱已重新抽取',
dedupeKey: `knowledge-graph:${libraryId}`
})
}}
onUpdateLibrary={async (libraryId, update) => {
await runKnowledgeSourceAction(async () => {
await window.goodbuddy.knowledge.updateLibrary(
libraryId,
update
)
})
}
notify({
tone: 'success',
message: '知识库设置已更新',
dedupeKey: `knowledge-library:${libraryId}`
})
}}
onDeleteRelation={(relationId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.deleteRelation(relationId)
@@ -5892,6 +6067,7 @@ function App(): React.JSX.Element {
}
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
sources={knowledgeSnapshot.sources}
tasks={knowledgeSnapshot.tasks}
/>
</PageShell>
) : view === 'heartbeat' ? (
+132 -43
View File
@@ -8,7 +8,7 @@ import {
type EChartsCoreOption
} from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
KnowledgeGraphNode,
KnowledgeGraphRelation
@@ -59,6 +59,42 @@ function readToken(name: string): string {
.trim()
}
function graphTypeStyles(nodes: readonly ChartKnowledgeGraphNode[]): Map<
string,
{ color: string; borderColor: string }
> {
const palette = Array.from({ length: 8 }, (_, index) => ({
color: readToken(`--graph-node-${index + 1}`),
borderColor: readToken(`--graph-node-${index + 1}-border`)
}))
return new Map(
[...new Set(nodes.map((node) => node.type))]
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
.map((type, index) => [type, palette[index % palette.length]!])
)
}
function graphRevision(
nodes: readonly ChartKnowledgeGraphNode[],
relations: readonly ChartKnowledgeGraphRelation[]
): string {
return JSON.stringify({
nodes: nodes.map((node) => [
node.id,
node.label,
node.type,
node.x,
node.y
]),
relations: relations.map((relation) => [
relation.id,
relation.sourceId,
relation.targetId,
relation.type
])
})
}
function createOption({
nodes,
relations,
@@ -72,17 +108,50 @@ function createOption({
const textSecondary = readToken('--text-secondary')
const textMuted = readToken('--text-muted')
const accent = readToken('--accent')
const accentSelected = readToken('--accent-selected')
const accentSubtle = readToken('--accent-subtle')
const surfaceRaised = readToken('--surface-raised')
const borderDefault = readToken('--border-default')
const typeStyles = graphTypeStyles(nodes)
const dense = nodes.length > 24
const veryDense = nodes.length > 60
const degreeByNodeId = new Map(nodes.map((node) => [node.id, 0]))
for (const relation of relations) {
degreeByNodeId.set(
relation.sourceId,
(degreeByNodeId.get(relation.sourceId) ?? 0) + 1
)
degreeByNodeId.set(
relation.targetId,
(degreeByNodeId.get(relation.targetId) ?? 0) + 1
)
}
const maximumDegree = Math.max(1, ...degreeByNodeId.values())
const keyNodeCount = Math.min(
nodes.length,
Math.max(8, Math.min(16, Math.round(Math.sqrt(nodes.length) * 1.4)))
)
const keyNodeIds = new Set(
[...nodes]
.sort((left, right) => {
const degreeDifference =
(degreeByNodeId.get(right.id) ?? 0) -
(degreeByNodeId.get(left.id) ?? 0)
return (
degreeDifference ||
left.label.localeCompare(right.label, 'zh-CN')
)
})
.slice(0, keyNodeCount)
.map((node) => node.id)
)
const reducedMotion =
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
const showEdgeLabels =
nodes.length <= 18 && relations.length <= 24
return {
animation: !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
animation: !reducedMotion,
animationDuration: 220,
animationDurationUpdate: 160,
tooltip: {
trigger: 'item',
renderMode: 'richText',
@@ -113,49 +182,54 @@ function createOption({
},
force: {
repulsion: dense
? Math.min(520, 130 + nodes.length * 3)
: 220,
gravity: dense ? 0.14 : 0.08,
edgeLength: dense
? veryDense
? [45, 80]
: [60, 110]
: [110, 190],
friction: dense ? 0.5 : 0.6,
layoutAnimation:
!window.matchMedia?.('(prefers-reduced-motion: reduce)')
.matches
? Math.min(480, 220 + nodes.length * 2)
: 200,
gravity: 0.06,
edgeLength: dense ? [70, 130] : [90, 150],
friction: 0.08,
layoutAnimation: !reducedMotion
},
selectedMode: 'single',
symbol: 'circle',
categories: [...typeStyles.entries()].map(([name, style]) => ({
name,
itemStyle: style
})),
data: nodes.map((node) => {
const selected = node.id === selectedNodeId
const typeStyle = typeStyles.get(node.type) ?? {
color: accentSubtle,
borderColor: accent
}
const degree = degreeByNodeId.get(node.id) ?? 0
const degreeRatio = Math.sqrt(degree / maximumDegree)
const symbolSize = dense
? 16 + degreeRatio * 16
: 32 + degreeRatio * 16
const showLabel = !dense || keyNodeIds.has(node.id)
return {
id: node.id,
name: node.label,
type: node.type,
...(dense ? {} : { x: node.x, y: node.y }),
value: degree,
category: node.type,
x: node.x,
y: node.y,
draggable: true,
selected,
symbolSize: selected
? dense
? 34
: 60
: dense
? veryDense
? 18
: 24
: 52,
symbolSize: selected ? symbolSize + 4 : symbolSize,
itemStyle: {
color: selected ? accentSelected : accentSubtle,
borderColor: accent,
borderWidth: selected ? 3 : 2
color: typeStyle.color,
borderColor: selected ? accent : typeStyle.borderColor,
borderWidth: selected ? 2.5 : 1.5
},
label: {
show: !dense || selected,
show: showLabel || selected,
color: textPrimary,
fontSize: dense ? 11 : 12,
fontWeight: 700,
fontWeight: keyNodeIds.has(node.id) ? 650 : 500,
position: dense ? 'right' : 'inside',
distance: dense ? 5 : 0,
formatter:
node.label.length > 8
? `${node.label.slice(0, 8)}`
@@ -163,15 +237,19 @@ function createOption({
},
emphasis: {
focus: 'adjacency',
itemStyle: {
borderColor: accent,
borderWidth: 2.5
},
label: {
show: true
}
},
select: {
itemStyle: {
color: accentSelected,
color: typeStyle.color,
borderColor: accent,
borderWidth: 3
borderWidth: 2.5
},
label: {
show: true
@@ -186,14 +264,14 @@ function createOption({
value: relation.type,
description: relation.description,
lineStyle: {
color: textMuted,
width: 1.5,
curveness: 0.08
color: borderDefault,
width: 1.2,
opacity: 0.72,
curveness: 0.06
}
})),
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 8,
autoCurveness: true,
edgeSymbolSize: 6,
edgeLabel: {
show: showEdgeLabels,
color: textSecondary,
@@ -229,12 +307,23 @@ export function KnowledgeGraphChart({
const onMoveNodeRef = useRef(onMoveNode)
const onSelectNodeRef = useRef(onSelectNode)
const onZoomChangeRef = useRef(onZoomChange)
const nodesRef = useRef(nodes)
const relationsRef = useRef(relations)
const dragRef = useRef<NodeDrag | undefined>(undefined)
const viewportRef = useRef<GraphViewport>({})
const zoomRef = useRef(zoom)
const appliedZoomRef = useRef<number | undefined>(undefined)
const dataRevision = useMemo(
() => graphRevision(nodes, relations),
[nodes, relations]
)
const [themeRevision, setThemeRevision] = useState(0)
useEffect(() => {
nodesRef.current = nodes
relationsRef.current = relations
}, [nodes, relations])
useEffect(() => {
onMoveNodeRef.current = onMoveNode
onSelectNodeRef.current = onSelectNode
@@ -417,8 +506,8 @@ export function KnowledgeGraphChart({
return
}
const option = createOption({
nodes,
relations,
nodes: nodesRef.current,
relations: relationsRef.current,
selectedNodeId: undefined,
zoom: zoomRef.current
})
@@ -437,7 +526,7 @@ export function KnowledgeGraphChart({
{ notMerge: true }
)
appliedZoomRef.current = zoomRef.current
}, [nodes, relations, themeRevision])
}, [dataRevision, themeRevision])
useEffect(() => {
const chart = chartRef.current
@@ -466,7 +555,7 @@ export function KnowledgeGraphChart({
seriesIndex: 0
})
const dataIndex = selectedNodeId
? nodes.findIndex((node) => node.id === selectedNodeId)
? nodesRef.current.findIndex((node) => node.id === selectedNodeId)
: -1
if (dataIndex >= 0) {
chart.dispatchAction({
@@ -475,7 +564,7 @@ export function KnowledgeGraphChart({
dataIndex
})
}
}, [nodes, selectedNodeId, themeRevision])
}, [dataRevision, selectedNodeId, themeRevision])
return (
<div
+141 -7
View File
@@ -129,6 +129,7 @@ function createProps(
onCreateLibrary: vi.fn(),
onDeleteLibrary: vi.fn(),
onUpdateLibrary: vi.fn(),
onReextractGraph: vi.fn(),
onImportFiles: vi.fn(),
onImportDirectory: vi.fn(),
onImportUrl: vi.fn(),
@@ -223,6 +224,91 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
})
it('uses shared tabs and keeps graph configuration in settings', () => {
const onUpdateLibrary = vi.fn()
render(<KnowledgeWorkspace {...createProps({ onUpdateLibrary })} />)
const tabs = screen.getByRole('tablist', { name: '知识库视图' })
expect(within(tabs).getAllByRole('tab').map((item) => item.textContent))
.toEqual(['文档与来源', '知识图谱', '任务中心', '设置'])
expect(
screen.queryByRole('checkbox', { name: '知识图谱' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
fireEvent.click(screen.getByRole('checkbox', { name: //u }))
expect(onUpdateLibrary).toHaveBeenCalledWith('library-1', {
graphEnabled: false
})
})
it('shows parsing, embedding, and graph progress in the task center', () => {
render(
<KnowledgeWorkspace
{...createProps({
tasks: [
{
id: 'task-1',
libraryId: 'library-1',
documentId: 'document-1',
documentName: '架构说明.md',
kind: 'graph',
status: 'running',
progress: 40,
message: '正在重新抽取知识图谱',
createdAt: '2026-08-10T08:00:00.000Z',
startedAt: '2026-08-10T08:00:01.000Z'
}
]
})}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: /^/u })
)
expect(screen.getByText('图谱抽取')).toBeInTheDocument()
expect(screen.getByText('正在重新抽取知识图谱')).toBeInTheDocument()
expect(
screen.getByRole('progressbar', {
name: '架构说明.md 图谱抽取进度'
})
).toHaveValue(40)
})
it('edits library metadata from the detail header', async () => {
const onUpdateLibrary = vi.fn()
render(<KnowledgeWorkspace {...createProps({ onUpdateLibrary })} />)
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '研发知识' }
})
fireEvent.change(screen.getByLabelText('描述'), {
target: { value: '研发资料与设计说明' }
})
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() =>
expect(onUpdateLibrary).toHaveBeenCalledWith('library-1', {
name: '研发知识',
description: '研发资料与设计说明'
})
)
})
it('reextracts the graph from the graph tab', async () => {
const onReextractGraph = vi.fn()
render(<KnowledgeWorkspace {...createProps({ onReextractGraph })} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
fireEvent.click(screen.getByRole('button', { name: '重新抽取' }))
await waitFor(() =>
expect(onReextractGraph).toHaveBeenCalledWith('library-1')
)
})
it('renders and filters graph nodes with their relationships', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
@@ -335,7 +421,7 @@ describe('KnowledgeWorkspace', () => {
it('manages the graph chart, zoom, selection, movement, and cleanup', () => {
const onMoveNode = vi.fn()
const { unmount } = render(
const { rerender, unmount } = render(
<KnowledgeWorkspace {...createProps({ onMoveNode })} />
)
@@ -351,15 +437,27 @@ describe('KnowledgeWorkspace', () => {
expect.objectContaining({
series: [
expect.objectContaining({
categories: expect.arrayContaining([
expect.objectContaining({ name: '产品' }),
expect.objectContaining({ name: '技术' })
]),
layout: 'force',
symbol: 'circle',
type: 'graph',
data: expect.arrayContaining([
expect.objectContaining({
category: '产品',
id: 'entity-1',
name: 'GoodBuddy'
})
]),
force: expect.objectContaining({
edgeLength: [90, 150],
friction: 0.08,
gravity: 0.06,
layoutAnimation: true,
repulsion: 200
}),
links: [
expect.objectContaining({
id: 'relation-1',
@@ -371,6 +469,12 @@ describe('KnowledgeWorkspace', () => {
}),
{ notMerge: true }
)
const stableOptionCallCount =
echartsMock.chart.setOption.mock.calls.length
rerender(<KnowledgeWorkspace {...createProps({ onMoveNode })} />)
expect(echartsMock.chart.setOption).toHaveBeenCalledTimes(
stableOptionCallCount
)
fireEvent.click(screen.getByRole('button', { name: '放大图谱' }))
expect(screen.getByText('115%')).toBeInTheDocument()
@@ -491,7 +595,7 @@ describe('KnowledgeWorkspace', () => {
delete document.documentElement.dataset.theme
})
it('reduces labels and node size for dense graphs', () => {
it('sizes dense nodes by degree and labels key entities', () => {
const graphNodes = Array.from({ length: 30 }, (_, index) => ({
id: `entity-${index}`,
label: `实体 ${index}`,
@@ -501,7 +605,23 @@ describe('KnowledgeWorkspace', () => {
}))
render(
<KnowledgeWorkspace
{...createProps({ graphNodes, graphRelations: [] })}
{...createProps({
graphNodes,
graphRelations: [
{
id: 'relation-dense-1',
sourceId: 'entity-0',
targetId: 'entity-1',
type: '关联'
},
{
id: 'relation-dense-2',
sourceId: 'entity-0',
targetId: 'entity-2',
type: '关联'
}
]
})}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
@@ -513,13 +633,27 @@ describe('KnowledgeWorkspace', () => {
data: expect.arrayContaining([
expect.objectContaining({
id: 'entity-0',
symbolSize: 24,
symbolSize: 32,
x: 0,
y: 0,
label: expect.objectContaining({
position: 'right',
show: true
})
}),
expect.objectContaining({
id: 'entity-29',
symbolSize: 16,
label: expect.objectContaining({ show: false })
})
]),
edgeLabel: expect.objectContaining({ show: false }),
force: expect.objectContaining({
repulsion: 220
edgeLength: [70, 130],
friction: 0.08,
gravity: 0.06,
layoutAnimation: true,
repulsion: 280
})
})
]
@@ -529,8 +663,8 @@ describe('KnowledgeWorkspace', () => {
const option = echartsMock.chart.setOption.mock.calls.at(-1)?.[0] as {
series?: Array<{ data?: Array<Record<string, unknown>> }>
}
expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('x')
expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('y')
expect(option.series?.[0]?.data?.[0]).toHaveProperty('x', 0)
expect(option.series?.[0]?.data?.[0]).toHaveProperty('y', 0)
})
it('creates relationships, merges entities, and opens graph evidence', async () => {
+456 -59
View File
@@ -12,6 +12,7 @@ import {
FolderOpen,
GitMerge,
Link2,
ListChecks,
LoaderCircle,
Network,
Pencil,
@@ -19,6 +20,7 @@ import {
RefreshCw,
RotateCcw,
Search,
Settings2,
Trash2,
UploadCloud,
X,
@@ -136,6 +138,21 @@ export type KnowledgeEvidence = {
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 KnowledgeEntityUpdate = {
label: string
type: string
@@ -158,6 +175,7 @@ export type KnowledgeWorkspaceProps = {
graphNodes: readonly KnowledgeGraphNode[]
graphRelations: readonly KnowledgeGraphRelation[]
evidence: readonly KnowledgeEvidence[]
tasks?: readonly KnowledgeTaskItem[]
loading?: boolean
loadError?: string
onRetryLoad: () => void | Promise<void>
@@ -169,10 +187,13 @@ export type KnowledgeWorkspaceProps = {
onUpdateLibrary: (
libraryId: string,
update: {
graphEnabled: boolean
graphStrategy: KnowledgeGraphStrategy
name?: string
description?: string
graphEnabled?: boolean
graphStrategy?: KnowledgeGraphStrategy
}
) => void | Promise<void>
onReextractGraph: (libraryId: string) => void | Promise<void>
onImportFiles: (
libraryId: string,
files: File[],
@@ -219,7 +240,7 @@ export type KnowledgeWorkspaceProps = {
onOpenEvidence?: (evidence: KnowledgeEvidence) => void
}
type WorkspaceTab = 'documents' | 'graph'
type WorkspaceTab = 'documents' | 'graph' | 'tasks' | 'settings'
const storageModeLabels: Record<KnowledgeStorageMode, string> = {
reference: '引用原文件',
@@ -590,6 +611,147 @@ function CreateLibraryWizard({
)
}
function EditLibraryDialog({
library,
onCancel,
onConfirm
}: {
library: KnowledgeLibrary
onCancel: () => void
onConfirm: (update: {
name: string
description: string
}) => void | Promise<void>
}): React.JSX.Element {
const [name, setName] = useState(library.name)
const [description, setDescription] = useState(library.description ?? '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const dialogRef = useRef<HTMLDivElement>(null)
const nameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
nameRef.current?.focus()
}, [])
const submit = async (
event: React.FormEvent<HTMLFormElement>
): Promise<void> => {
event.preventDefault()
const normalizedName = name.trim()
if (!normalizedName) {
setError('请输入知识库名称')
return
}
setSaving(true)
setError(undefined)
try {
await onConfirm({
name: normalizedName,
description: description.trim()
})
onCancel()
} catch (reason) {
setError(toErrorMessage(reason))
} finally {
setSaving(false)
}
}
return (
<div
aria-label="编辑知识库"
aria-modal="true"
onKeyDown={(event) => {
if (event.key === 'Escape' && !saving) {
event.preventDefault()
onCancel()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
style={{
position: 'fixed',
inset: 0,
zIndex: 50,
display: 'grid',
placeItems: 'center',
padding: 20,
background: 'var(--overlay-backdrop)'
}}
>
<form
aria-label="编辑知识库表单"
onSubmit={(event) => void submit(event)}
style={{
...styles.surface,
display: 'grid',
width: 'min(480px, 100%)',
padding: 20,
boxShadow: 'var(--shadow-dialog)',
gap: 14
}}
>
<div>
<h2 style={{ margin: 0 }}></h2>
<p style={{ ...styles.muted, margin: '6px 0 0' }}>
</p>
</div>
<label style={styles.label}>
<input
onChange={(event) => setName(event.currentTarget.value)}
ref={nameRef}
style={styles.input}
value={name}
/>
</label>
<label style={styles.label}>
<textarea
onChange={(event) => setDescription(event.currentTarget.value)}
rows={4}
style={{ ...styles.input, resize: 'vertical' }}
value={description}
/>
</label>
{error && (
<p role="alert" style={{ color: 'var(--danger)', margin: 0 }}>
{error}
</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button
className="secondary-button"
disabled={saving}
onClick={onCancel}
style={styles.button}
type="button"
>
</button>
<button
className="primary-button"
disabled={saving}
style={styles.button}
type="submit"
>
{saving ? (
<LoaderCircle aria-hidden="true" size={15} />
) : (
<Check aria-hidden="true" size={15} />
)}
{saving ? '保存中…' : '保存修改'}
</button>
</div>
</form>
</div>
)
}
function DeleteLibraryDialog({
library,
onCancel,
@@ -1488,10 +1650,192 @@ function RelationForm({
)
}
function KnowledgeSettingsView({
library,
onUpdateLibrary
}: {
library: KnowledgeLibrary
onUpdateLibrary: KnowledgeWorkspaceProps['onUpdateLibrary']
}): React.JSX.Element {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const update = async (
change: Parameters<KnowledgeWorkspaceProps['onUpdateLibrary']>[1]
): Promise<void> => {
setSaving(true)
setError(undefined)
try {
await onUpdateLibrary(library.id, change)
} catch (reason) {
setError(toErrorMessage(reason))
} finally {
setSaving(false)
}
}
return (
<div className="knowledge-settings">
<section
aria-labelledby="knowledge-graph-settings-title"
style={{ ...styles.surface, padding: 16 }}
>
<div>
<h3 id="knowledge-graph-settings-title" style={{ margin: 0 }}>
</h3>
<p style={{ ...styles.muted, margin: '6px 0 0' }}>
</p>
</div>
<label
className="knowledge-settings__toggle"
style={{ ...styles.surface, cursor: saving ? 'wait' : 'pointer' }}
>
<input
checked={library.graphEnabled}
disabled={saving}
onChange={(event) =>
void update({ graphEnabled: event.currentTarget.checked })
}
type="checkbox"
/>
<span>
<strong style={{ display: 'block' }}></strong>
<span style={styles.muted}>
</span>
</span>
</label>
<label style={styles.label}>
<select
aria-label="知识图谱抽取策略"
disabled={!library.graphEnabled || saving}
onChange={(event) =>
void update({
graphStrategy:
event.currentTarget.value as KnowledgeGraphStrategy
})
}
style={styles.input}
value={library.graphStrategy}
>
{Object.entries(strategyLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<span style={styles.muted}>
</span>
</label>
{error && (
<p role="alert" style={{ color: 'var(--danger)', margin: 0 }}>
{error}
</p>
)}
</section>
</div>
)
}
const taskKindLabels: Record<KnowledgeTaskItem['kind'], string> = {
parsing: '文档解析',
embedding: '向量化',
graph: '图谱抽取'
}
const taskStatusLabels: Record<KnowledgeTaskItem['status'], string> = {
queued: '等待中',
running: '进行中',
succeeded: '已完成',
failed: '失败',
skipped: '已跳过'
}
function KnowledgeTasksView({
tasks
}: {
tasks: readonly KnowledgeTaskItem[]
}): React.JSX.Element {
const activeCount = tasks.filter(
(task) => task.status === 'queued' || task.status === 'running'
).length
const failedCount = tasks.filter(
(task) => task.status === 'failed'
).length
if (tasks.length === 0) {
return (
<EmptyState
description="导入或同步文档后,可以在这里查看解析、向量化和图谱抽取进度。"
icon={<ListChecks size={30} />}
level="section"
title="还没有知识任务"
/>
)
}
return (
<section aria-labelledby="knowledge-tasks-title">
<div className="knowledge-tasks__summary">
<div>
<h3 id="knowledge-tasks-title" style={{ margin: 0 }}>
</h3>
<p style={{ ...styles.muted, margin: '5px 0 0' }}>
{tasks.length}
</p>
</div>
<div className="knowledge-tasks__metrics">
<span> {activeCount}</span>
<span> {failedCount}</span>
</div>
</div>
<ol className="knowledge-task-list">
{tasks.map((task) => (
<li className="knowledge-task" key={task.id}>
<div className="knowledge-task__heading">
<div>
<strong>{task.documentName}</strong>
<span>{taskKindLabels[task.kind]}</span>
</div>
<span
className={`knowledge-task__status knowledge-task__status--${task.status}`}
>
{taskStatusLabels[task.status]}
</span>
</div>
<div className="knowledge-task__progress">
<progress
aria-label={`${task.documentName} ${taskKindLabels[task.kind]}进度`}
max={100}
value={task.progress}
/>
<span>{task.progress}%</span>
</div>
<div className="knowledge-task__meta">
<span>{task.message || '等待处理'}</span>
<time dateTime={task.completedAt ?? task.startedAt ?? task.createdAt}>
{new Date(
task.completedAt ?? task.startedAt ?? task.createdAt
).toLocaleString('zh-CN')}
</time>
</div>
</li>
))}
</ol>
</section>
)
}
function GraphView({
evidence,
graphNodes,
graphRelations,
libraryId,
onCreateEntity,
onCreateRelation,
onDeleteEntity,
@@ -1499,6 +1843,7 @@ function GraphView({
onMergeEntities,
onMoveNode,
onOpenEvidence,
onReextractGraph,
onUpdateEntity,
onUpdateRelation
}: Pick<
@@ -1513,9 +1858,12 @@ function GraphView({
| 'onMergeEntities'
| 'onMoveNode'
| 'onOpenEvidence'
| 'onReextractGraph'
| 'onUpdateEntity'
| 'onUpdateRelation'
>): React.JSX.Element {
> & {
libraryId: string
}): React.JSX.Element {
const [query, setQuery] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [selectedNodeId, setSelectedNodeId] = useState<string>()
@@ -1526,6 +1874,8 @@ function GraphView({
const [mergeTargetId, setMergeTargetId] = useState('')
const [zoom, setZoom] = useState(1)
const [relationsExpanded, setRelationsExpanded] = useState(false)
const [reextracting, setReextracting] = useState(false)
const [reextractError, setReextractError] = useState<string>()
const nodeMap = useMemo(
() => new Map(graphNodes.map((node) => [node.id, node])),
@@ -1658,6 +2008,22 @@ function GraphView({
</option>
))}
</select>
<button
className="secondary-button"
disabled={reextracting}
onClick={() => {
setReextracting(true)
setReextractError(undefined)
void Promise.resolve(onReextractGraph(libraryId))
.catch((reason) => setReextractError(toErrorMessage(reason)))
.finally(() => setReextracting(false))
}}
style={styles.button}
type="button"
>
<RefreshCw aria-hidden="true" size={15} />
{reextracting ? '重新抽取中…' : '重新抽取'}
</button>
<button
className="secondary-button"
onClick={() => {
@@ -1704,6 +2070,14 @@ function GraphView({
>
<ZoomIn aria-hidden="true" size={16} />
</button>
{reextractError && (
<span
className="knowledge-graph__toolbar-error"
role="alert"
>
{reextractError}
</span>
)}
</div>
{graphNodes.length === 0 ? (
<div
@@ -2117,6 +2491,7 @@ export function KnowledgeWorkspace({
graphNodes,
graphRelations,
evidence,
tasks = [],
loading = false,
loadError,
onRetryLoad,
@@ -2124,6 +2499,7 @@ export function KnowledgeWorkspace({
onCreateLibrary,
onDeleteLibrary,
onUpdateLibrary,
onReextractGraph,
onImportFiles,
onImportDirectory,
onImportUrl,
@@ -2144,8 +2520,11 @@ export function KnowledgeWorkspace({
const [creating, setCreating] = useState(false)
const [mobileListOpen, setMobileListOpen] = useState(false)
const [tab, setTab] = useState<WorkspaceTab>('documents')
const [editingLibrary, setEditingLibrary] =
useState<KnowledgeLibrary>()
const [deletingLibrary, setDeletingLibrary] =
useState<KnowledgeLibrary>()
const editLibraryTriggerRef = useRef<HTMLButtonElement>(null)
const deleteLibraryTriggerRef = useRef<HTMLButtonElement>(null)
const selectedLibrary =
libraries.find((library) => library.id === selectedLibraryId) ??
@@ -2158,6 +2537,9 @@ export function KnowledgeWorkspace({
(document) => document.libraryId === selectedLibrary.id
)
: []
const libraryTasks = selectedLibrary
? tasks.filter((task) => task.libraryId === selectedLibrary.id)
: []
useEffect(() => {
if (
@@ -2167,24 +2549,35 @@ export function KnowledgeWorkspace({
onSelectLibrary(selectedLibrary.id)
}
}, [onSelectLibrary, selectedLibrary, selectedLibraryId])
const visibleTab =
selectedLibrary?.graphEnabled === false ? 'documents' : tab
const visibleTab = tab
const workspaceTabs: ReadonlyArray<PageTab<WorkspaceTab>> = [
{
id: 'documents',
label: '文档与来源',
icon: <FileText aria-hidden="true" size={15} />
},
...(selectedLibrary?.graphEnabled
? [
{
id: 'graph' as const,
label: '知识图谱',
icon: <Network aria-hidden="true" size={15} />
}
]
: [])
{
id: 'graph',
label: '知识图谱',
icon: <Network aria-hidden="true" size={15} />
},
{
id: 'tasks',
label: '任务中心',
icon: <ListChecks aria-hidden="true" size={15} />
},
{
id: 'settings',
label: '设置',
icon: <Settings2 aria-hidden="true" size={15} />
}
]
const closeEditDialog = (): void => {
setEditingLibrary(undefined)
requestAnimationFrame(() =>
editLibraryTriggerRef.current?.focus()
)
}
const closeDeleteDialog = (): void => {
setDeletingLibrary(undefined)
requestAnimationFrame(() =>
@@ -2445,49 +2838,16 @@ export function KnowledgeWorkspace({
<div
className="knowledge-workspace__header-actions"
>
<label
style={{
display: 'flex',
alignItems: 'center',
color: 'var(--text-secondary)',
fontSize: 12,
gap: 6
}}
<button
className="secondary-button"
onClick={() => setEditingLibrary(selectedLibrary)}
ref={editLibraryTriggerRef}
style={styles.button}
type="button"
>
<input
checked={selectedLibrary.graphEnabled}
onChange={(event) =>
void onUpdateLibrary(selectedLibrary.id, {
graphEnabled: event.currentTarget.checked,
graphStrategy: selectedLibrary.graphStrategy
})
}
type="checkbox"
/>
</label>
{selectedLibrary.graphEnabled && (
<select
aria-label="知识图谱抽取策略"
className="knowledge-workspace__strategy"
onChange={(event) =>
void onUpdateLibrary(selectedLibrary.id, {
graphEnabled: true,
graphStrategy:
event.currentTarget
.value as KnowledgeGraphStrategy
})
}
style={styles.input}
value={selectedLibrary.graphStrategy}
>
{Object.entries(strategyLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
)}
<Pencil aria-hidden="true" size={15} />
</button>
<button
aria-label={`删除知识库 ${selectedLibrary.name}`}
className="danger-button danger-button--quiet"
@@ -2497,7 +2857,7 @@ export function KnowledgeWorkspace({
type="button"
>
<Trash2 aria-hidden="true" size={15} />
</button>
</div>
</header>
@@ -2529,11 +2889,13 @@ export function KnowledgeWorkspace({
onSyncSource={onSyncSource}
sources={librarySources}
/>
) : (
) : visibleTab === 'graph' &&
selectedLibrary.graphEnabled ? (
<GraphView
evidence={evidence}
graphNodes={graphNodes}
graphRelations={graphRelations}
libraryId={selectedLibrary.id}
onCreateEntity={onCreateEntity}
onCreateRelation={onCreateRelation}
onDeleteEntity={onDeleteEntity}
@@ -2541,14 +2903,49 @@ export function KnowledgeWorkspace({
onMergeEntities={onMergeEntities}
onMoveNode={onMoveNode}
onOpenEvidence={onOpenEvidence}
onReextractGraph={onReextractGraph}
onUpdateEntity={onUpdateEntity}
onUpdateRelation={onUpdateRelation}
/>
) : visibleTab === 'graph' ? (
<EmptyState
action={
<button
className="secondary-button"
onClick={() => setTab('settings')}
style={styles.button}
type="button"
>
<Settings2 aria-hidden="true" size={15} />
</button>
}
description="在“设置”中启用知识图谱后,可以查看实体关系并重新抽取。"
icon={<Network size={30} />}
level="section"
title="知识图谱未启用"
/>
) : visibleTab === 'tasks' ? (
<KnowledgeTasksView tasks={libraryTasks} />
) : (
<KnowledgeSettingsView
library={selectedLibrary}
onUpdateLibrary={onUpdateLibrary}
/>
)}
</div>
</>
)}
</section>
{editingLibrary && (
<EditLibraryDialog
library={editingLibrary}
onCancel={closeEditDialog}
onConfirm={(update) =>
onUpdateLibrary(editingLibrary.id, update)
}
/>
)}
{deletingLibrary && (
<DeleteLibraryDialog
library={deletingLibrary}
+24
View File
@@ -497,6 +497,30 @@ describe('SettingsPanel runtime files', () => {
expect(content).toHaveClass('settings-panel__content')
})
it('omits the redundant close-only footer on passive settings pages', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
screen.getByRole('button', { name: '关闭设置' })
).toBeInTheDocument()
expect(
screen
.getByRole('region', { name: '设置中心' })
.querySelector('.settings-panel__footer')
).toBeNull()
})
it('uses one first-level heading for the settings page', () => {
render(
<SettingsPanel
+49 -38
View File
@@ -346,6 +346,11 @@ export function SettingsPanel({
activeTab === 'runtime' ||
activeTab === 'security' ||
activeTab === 'roles'
const showFooter =
presentation !== 'page' ||
configurationTab ||
Boolean(error) ||
saved
const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
@@ -2297,49 +2302,55 @@ export function SettingsPanel({
</div>
</div>
<footer className="settings-panel__footer">
<div className="settings-feedback">
{error && <span className="settings-error">{error}</span>}
{saved && (
<span className="settings-success">
<Check size={14} />
{connectionResult ?? '设置已保存'}
</span>
)}
</div>
<button className="secondary-button" onClick={close} type="button">
{configurationTab ? '取消' : '关闭'}
</button>
{configurationTab && (
<>
{(activeTab === 'runtime' ||
(activeTab === 'model' && modelType === 'llm')) && (
{showFooter && (
<footer className="settings-panel__footer">
<div className="settings-feedback">
{error && <span className="settings-error">{error}</span>}
{saved && (
<span className="settings-success">
<Check size={14} />
{connectionResult ?? '设置已保存'}
</span>
)}
</div>
<button
className="secondary-button"
onClick={close}
type="button"
>
{configurationTab ? '取消' : '关闭'}
</button>
{configurationTab && (
<>
{(activeTab === 'runtime' ||
(activeTab === 'model' && modelType === 'llm')) && (
<button
className="secondary-button"
disabled={saving || testing}
onClick={() => void testConnection()}
type="button"
>
{testing
? '测试中…'
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
</button>
)}
<button
className="secondary-button"
className="primary-button"
disabled={saving || testing}
onClick={() => void testConnection()}
onClick={() => void save()}
type="button"
>
{testing
? '测试中…'
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
{saving ? '保存中…' : '保存设置'}
</button>
)}
<button
className="primary-button"
disabled={saving || testing}
onClick={() => void save()}
type="button"
>
{saving ? '保存中…' : '保存设置'}
</button>
</>
)}
</footer>
</>
)}
</footer>
)}
</section>
</div>
)
File diff suppressed because it is too large Load Diff
+25 -4
View File
@@ -800,8 +800,10 @@ export const knowledgeUrlImportSchema = z
export const knowledgeUpdateLibrarySchema = z
.object({
libraryId: knowledgeIdSchema,
graphEnabled: z.boolean(),
graphStrategy: z.enum(['rules', 'model', 'hybrid', 'ask'])
name: z.string().trim().min(1).max(120).optional(),
description: z.string().trim().max(1_000).optional(),
graphEnabled: z.boolean().optional(),
graphStrategy: z.enum(['rules', 'model', 'hybrid', 'ask']).optional()
})
.strict()
export const knowledgeEntityUpdateSchema = z
@@ -856,6 +858,21 @@ export type KnowledgeDocumentItem = {
error?: 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 = {
id: string
label: string
@@ -892,6 +909,7 @@ export type KnowledgeSnapshot = {
graphNodes: KnowledgeGraphNode[]
graphRelations: KnowledgeGraphRelation[]
evidence: KnowledgeEvidence[]
tasks?: KnowledgeTaskItem[]
}
export type KnowledgeSearchReference = {
@@ -1185,11 +1203,14 @@ export type DesktopApi = {
updateLibrary: (
libraryId: string,
update: {
graphEnabled: boolean
graphStrategy: 'rules' | 'model' | 'hybrid' | 'ask'
name?: string
description?: string
graphEnabled?: boolean
graphStrategy?: 'rules' | 'model' | 'hybrid' | 'ask'
}
) => Promise<void>
deleteLibrary: (libraryId: string) => Promise<void>
reextractGraph: (libraryId: string) => Promise<void>
selectFiles: (
libraryId: string,
graphStrategy?: 'rules' | 'model' | 'hybrid'
+1
View File
@@ -133,6 +133,7 @@ export const ipcChannels = {
knowledgeCreateLibrary: 'knowledge:library:create',
knowledgeUpdateLibrary: 'knowledge:library:update',
knowledgeDeleteLibrary: 'knowledge:library:delete',
knowledgeReextractGraph: 'knowledge:graph:reextract',
knowledgeSelectFiles: 'knowledge:source:select-files',
knowledgeSelectDirectory: 'knowledge:source:select-directory',
knowledgeImportPaths: 'knowledge:source:import-paths',