feat: enhance knowledge workflows and refresh interface
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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' ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
+740
-583
File diff suppressed because it is too large
Load Diff
+25
-4
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user