feat: add external knowledge retrieval and cross-project activity
Connect Dify, FastGPT and RAGFlow through read-only bindings, encrypted local credentials, shared retrieval and persistent citations. Configuration and removal affect GoodBuddy only. Show active conversations across projects, retain older live work, and improve project dialog scrolling. Queue concurrent questions and route managed SSH OpenCode questions through the Agent. Preserve recoverable read/search errors and partial search results. Document the Electron UI automation standard, real model and external knowledge validation, and remaining acceptance limits. Validation: earlier full regression passed 4081 tests with 66 skipped; subsequent focused remote-question checks and real Linux evidence are recorded in feature progress. Precommit typecheck and lint passed; latest project dialog and shared UI suites passed 58 tests. The full suite was not rerun for the final dialog changes. Release note: Use external knowledge in conversations, find active work across projects, and answer concurrent local or managed SSH OpenCode questions. Managed SSH questions require the updated Agent adapter.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
type SessionNotification
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { canonicalJson } from '../shared/agent-protocol/canonical'
|
||||
import { remoteQuestionSchema, type RemoteQuestionResponse } from '../shared/remote-question-contracts'
|
||||
import type {
|
||||
RemoteOwnedPromptStartRequest,
|
||||
RemoteOwnedPromptStartResult
|
||||
@@ -57,6 +58,10 @@ export class AgentOwnedAcpPrompt {
|
||||
#promptPromise?: Promise<void>
|
||||
#closed = false
|
||||
#initialized = false
|
||||
readonly #questions = new Map<string, {
|
||||
endpoint: string; operationId: string; questionCount: number; notification: SessionNotification
|
||||
}>()
|
||||
#acceptQuestions = false
|
||||
#active?: {
|
||||
operationId: string
|
||||
}
|
||||
@@ -94,6 +99,8 @@ export class AgentOwnedAcpPrompt {
|
||||
return
|
||||
}
|
||||
const error = new Error('ACP Runtime process exited')
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
controller?.error(error)
|
||||
controller = undefined
|
||||
rejectProcessExit(error)
|
||||
@@ -213,6 +220,7 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#active = {
|
||||
operationId: request.operationId
|
||||
}
|
||||
this.#acceptQuestions = true
|
||||
const begun = this.#options.transcript.begin({
|
||||
bindingId: this.#options.bindingId,
|
||||
operationId: request.operationId,
|
||||
@@ -277,6 +285,8 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
this.#questions.clear()
|
||||
this.#acceptQuestions = false
|
||||
this.#active = undefined
|
||||
})
|
||||
void this.#promptPromise.catch(() => undefined)
|
||||
@@ -284,6 +294,8 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
if (this.#sessionId !== undefined && !this.#closed) {
|
||||
await this.#connection.cancel({ sessionId: this.#sessionId })
|
||||
}
|
||||
@@ -293,6 +305,29 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#clear()
|
||||
}
|
||||
|
||||
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
|
||||
const pending = this.#questions.get(request.questionId)
|
||||
if (this.#closed || !pending || this.#active?.operationId !== request.operationId ||
|
||||
pending.operationId !== request.operationId || request.bindingId !== this.#options.bindingId) {
|
||||
throw new Error('Remote question is no longer pending for this prompt')
|
||||
}
|
||||
if (request.answers.length !== 0 && request.answers.length !== pending.questionCount) {
|
||||
throw new Error('Question answer count does not match')
|
||||
}
|
||||
const response = await fetch(pending.endpoint, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, sessionId: this.#sessionId }),
|
||||
signal: AbortSignal.timeout(30_000), redirect: 'error'
|
||||
})
|
||||
if (!response.ok) throw new Error(`OpenCode question response failed (${response.status})`)
|
||||
this.#questions.delete(request.questionId)
|
||||
}
|
||||
|
||||
pendingQuestions(operationId: string): SessionNotification[] {
|
||||
if (!this.#acceptQuestions || this.#active?.operationId !== operationId) return []
|
||||
return [...this.#questions.values()].map(question => question.notification)
|
||||
}
|
||||
|
||||
#client(): Client {
|
||||
return {
|
||||
requestPermission: async (request) =>
|
||||
@@ -306,12 +341,38 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
|
||||
#handleSessionUpdate(notification: SessionNotification): void {
|
||||
if (this.#closed || !this.#active) return
|
||||
if (
|
||||
notification.sessionId !== this.#sessionId &&
|
||||
this.#sessionId !== undefined
|
||||
) {
|
||||
throw new Error('ACP session notification identity changed')
|
||||
}
|
||||
const resolved = notification.update._meta?.goodbuddyQuestionResolved
|
||||
if (typeof resolved === 'string') {
|
||||
this.#questions.delete(resolved)
|
||||
return
|
||||
}
|
||||
const extension = notification.update._meta?.goodbuddyQuestion
|
||||
if (extension !== undefined) {
|
||||
if (!this.#acceptQuestions) return
|
||||
if (!extension || typeof extension !== 'object') throw new Error('Invalid remote question')
|
||||
const value = extension as { question?: unknown; endpoint?: unknown; childCallId?: unknown }
|
||||
const question = remoteQuestionSchema.parse(value.question)
|
||||
if (typeof value.endpoint !== 'string' ||
|
||||
!/^http:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9_-]{43}$/u.test(value.endpoint)) {
|
||||
throw new Error('Invalid remote question endpoint')
|
||||
}
|
||||
if (this.#questions.has(question.id)) return
|
||||
// The process-local reply capability never enters the durable transcript.
|
||||
notification = { ...notification, update: {
|
||||
...notification.update, _meta: { goodbuddyQuestion: {
|
||||
question, ...(typeof value.childCallId === 'string' ? { childCallId: value.childCallId } : {})
|
||||
} }
|
||||
} }
|
||||
this.#questions.set(question.id, { endpoint: value.endpoint, operationId: this.#active.operationId,
|
||||
questionCount: question.questions.length, notification })
|
||||
}
|
||||
this.#options.transcript.append({
|
||||
bindingId: this.#options.bindingId,
|
||||
operationId: this.#requireActive().operationId,
|
||||
@@ -371,6 +432,8 @@ export class AgentOwnedAcpPrompt {
|
||||
return
|
||||
}
|
||||
this.#closed = true
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
this.#unsubscribeOutput()
|
||||
this.#unsubscribeExit()
|
||||
}
|
||||
|
||||
@@ -871,7 +871,8 @@ function credentialFreeHelperEnvironment(
|
||||
'XDG_STATE_HOME'
|
||||
] as const
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
OPENCODE_CONFIG_CONTENT: config
|
||||
OPENCODE_CONFIG_CONTENT: config,
|
||||
OPENCODE_ENABLE_QUESTION_TOOL: 'true'
|
||||
}
|
||||
for (const name of allowed) {
|
||||
const value = source[name]
|
||||
|
||||
@@ -117,6 +117,7 @@ describe('model bridge loopback helper', () => {
|
||||
expect(args).toEqual(['acp'])
|
||||
expect(options).toMatchObject({ shell: false, stdio: 'inherit' })
|
||||
expect(options.env).toMatchObject({
|
||||
OPENCODE_ENABLE_QUESTION_TOOL: 'true',
|
||||
...resources,
|
||||
LANG: profile.env.LANG,
|
||||
LC_ALL: profile.env.LC_ALL,
|
||||
|
||||
@@ -1,13 +1,74 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { createServer } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
|
||||
describe('OpenCode ACP child event plugin', () => {
|
||||
it('authenticates native replies, checks live ownership, rejects stale events and closes its endpoint', async () => {
|
||||
const write = vi.fn()
|
||||
const question = { id: 'q', sessionID: 'grandchild', questions: [
|
||||
{ header: 'Input', question: 'Choose', options: [] }
|
||||
] }
|
||||
let listed: unknown[] = [question]
|
||||
const get = vi.fn(async () => ({ data: listed }))
|
||||
const post = vi.fn(async () => ({}))
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
|
||||
{ process: { stdout: { write } }, createServer, randomBytes }
|
||||
)
|
||||
const hooks = await factory({ client: {
|
||||
_client: { get, post }, session: { get: async ({ path }: { path: { id: string } }) => ({
|
||||
data: { id: path.id, parentID: path.id === 'grandchild' ? 'child' : path.id === 'child' ? 'parent' : undefined }
|
||||
}) }
|
||||
} })
|
||||
try {
|
||||
for (const [sessionID, child, callID] of [['parent', 'child', 'task'], ['child', 'grandchild', 'nested']]) {
|
||||
await hooks.event({ event: { type: 'message.part.updated', properties: { part: {
|
||||
type: 'tool', tool: 'task', sessionID, callID, state: { metadata: { sessionId: child } }
|
||||
} } } })
|
||||
}
|
||||
write.mockClear()
|
||||
await Promise.all([
|
||||
hooks.event({ event: { type: 'question.asked', properties: question } }),
|
||||
hooks.event({ event: { type: 'question.asked', properties: question } })
|
||||
])
|
||||
expect(write).toHaveBeenCalledOnce()
|
||||
const notification = JSON.parse(write.mock.calls[0]![0]).params
|
||||
expect(notification.sessionId).toBe('parent')
|
||||
const extension = notification.update._meta.goodbuddyQuestion
|
||||
expect(extension.childCallId).toBe('task')
|
||||
const send = (url: string, sessionId: string, answers: string[][]) => fetch(url, {
|
||||
method: 'POST', body: JSON.stringify({ questionId: 'q', sessionId, answers })
|
||||
})
|
||||
expect((await send(extension.endpoint.replace(/.$/u, '!'), 'parent', [['A']])).status).toBe(404)
|
||||
expect((await send(extension.endpoint, 'other', [['A']])).status).toBe(409)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect((await send(extension.endpoint, 'parent', [['A']])).status).toBe(200)
|
||||
expect(post).toHaveBeenLastCalledWith({ url: '/question/q/reply', body: { answers: [['A']] }, throwOnError: true })
|
||||
expect((await send(extension.endpoint, 'parent', [['A']])).status).toBe(409)
|
||||
listed = []
|
||||
await hooks.event({ event: { type: 'question.asked', properties: question } })
|
||||
expect(write).toHaveBeenCalledOnce()
|
||||
listed = [question]
|
||||
await hooks.event({ event: { type: 'question.asked', properties: question } })
|
||||
expect((await send(extension.endpoint, 'parent', [])).status).toBe(200)
|
||||
expect(post).toHaveBeenLastCalledWith({ url: '/question/q/reject', throwOnError: true })
|
||||
await hooks.dispose()
|
||||
await expect(send(extension.endpoint, 'parent', [])).rejects.toThrow()
|
||||
write.mockClear()
|
||||
await hooks.event({ event: { type: 'question.asked', properties: question } })
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await hooks.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits valid newline-delimited ACP updates only for owned child sessions', async () => {
|
||||
const write = vi.fn()
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource().replace('export default ', '')})`,
|
||||
{ process: { stdout: { write } } }
|
||||
`(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
|
||||
{ process: { stdout: { write } }, createServer: undefined, randomBytes: undefined }
|
||||
)
|
||||
const hooks = await factory()
|
||||
const event = {
|
||||
|
||||
@@ -1,20 +1,98 @@
|
||||
/**
|
||||
* OpenCode's ACP adapter omits native child sessions. This plugin forwards
|
||||
* their SDK events as ACP metadata on the owning Task, using the same stdio
|
||||
* stream and prompt lifetime as the built-in adapter.
|
||||
* OpenCode's ACP adapter omits native questions and child sessions. Native
|
||||
* events use ACP metadata; question replies use a process-local capability.
|
||||
*/
|
||||
export function openCodeSubagentPluginSource(): string {
|
||||
return `export default ${plugin.toString()}`
|
||||
return `import { createServer } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes })`
|
||||
}
|
||||
|
||||
function plugin() {
|
||||
async function plugin(input?: {
|
||||
client: {
|
||||
_client: {
|
||||
get(input: { url: string; throwOnError: true }): Promise<{ data: unknown }>
|
||||
post(input: { url: string; body?: unknown; throwOnError: true }): Promise<unknown>
|
||||
}
|
||||
session: { get(input: { path: { id: string }; throwOnError: true }): Promise<{
|
||||
data: { id: string; parentID?: string }
|
||||
}> }
|
||||
}
|
||||
}, modules?: {
|
||||
createServer: typeof import('node:http').createServer
|
||||
randomBytes: typeof import('node:crypto').randomBytes
|
||||
}) {
|
||||
const tasks = new Map<string, { sessionId: string; callId: string }>()
|
||||
const pending = new Map<string, { sessionId: string; root: string }>()
|
||||
let endpoint: string | undefined
|
||||
let closed = false
|
||||
let closeServer: (() => void) | undefined
|
||||
if (input) {
|
||||
const { createServer, randomBytes } = modules!
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method !== 'POST' || request.url !== `/${token}`) {
|
||||
response.writeHead(404).end()
|
||||
return
|
||||
}
|
||||
let body = ''
|
||||
for await (const chunk of request) {
|
||||
body += chunk
|
||||
if (body.length > 1024 * 1024) throw new Error('Question answer too large')
|
||||
}
|
||||
const answer = JSON.parse(body) as {
|
||||
questionId: string; sessionId: string; answers: string[][]
|
||||
}
|
||||
const question = pending.get(answer.questionId)
|
||||
if (!question || question.root !== answer.sessionId) {
|
||||
response.writeHead(409).end()
|
||||
return
|
||||
}
|
||||
const listed = await input.client._client.get({ url: '/question', throwOnError: true })
|
||||
if (!Array.isArray(listed.data) || !listed.data.some((item) =>
|
||||
item.id === answer.questionId && item.sessionID === question.sessionId
|
||||
)) {
|
||||
pending.delete(answer.questionId)
|
||||
response.writeHead(409).end()
|
||||
return
|
||||
}
|
||||
await input.client._client.post({
|
||||
url: `/question/${encodeURIComponent(answer.questionId)}/${answer.answers.length ? 'reply' : 'reject'}`,
|
||||
...(answer.answers.length ? { body: { answers: answer.answers } } : {}),
|
||||
throwOnError: true
|
||||
})
|
||||
pending.delete(answer.questionId)
|
||||
response.writeHead(200).end('{}')
|
||||
} catch {
|
||||
response.writeHead(500).end()
|
||||
}
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
server.unref()
|
||||
closeServer = () => { server.closeAllConnections(); server.close() }
|
||||
const address = server.address()
|
||||
if (address && typeof address !== 'string') endpoint = `http://127.0.0.1:${address.port}/${token}`
|
||||
}
|
||||
return Promise.resolve({
|
||||
dispose: async () => {
|
||||
closed = true
|
||||
pending.clear()
|
||||
tasks.clear()
|
||||
closeServer?.()
|
||||
},
|
||||
event: async ({ event }: { event: {
|
||||
type: string
|
||||
properties: {
|
||||
id?: string
|
||||
requestID?: string
|
||||
status?: { type?: string }
|
||||
sessionID?: string
|
||||
part?: {
|
||||
sessionID?: string
|
||||
type: string
|
||||
tool?: string
|
||||
callID?: string
|
||||
@@ -25,14 +103,69 @@ function plugin() {
|
||||
}
|
||||
} }) => {
|
||||
const properties = event.properties
|
||||
if (closed) return
|
||||
if (event.type === 'session.status' && properties.status?.type === 'idle' && properties.sessionID) {
|
||||
for (const [id, question] of pending) {
|
||||
if (question.root !== properties.sessionID && question.sessionId !== properties.sessionID) continue
|
||||
pending.delete(id)
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {
|
||||
sessionId: question.root, update: { sessionUpdate: 'tool_call_update', toolCallId: id,
|
||||
_meta: { goodbuddyQuestionResolved: id } }
|
||||
} }) + '\n')
|
||||
}
|
||||
for (const [child, owner] of tasks) {
|
||||
if (owner.sessionId === properties.sessionID) tasks.delete(child)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'question.replied' || event.type === 'question.rejected') {
|
||||
const question = properties.requestID ? pending.get(properties.requestID) : undefined
|
||||
if (question) {
|
||||
pending.delete(properties.requestID!)
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {
|
||||
sessionId: question.root, update: { sessionUpdate: 'tool_call_update', toolCallId: properties.requestID,
|
||||
_meta: { goodbuddyQuestionResolved: properties.requestID } }
|
||||
} }) + '\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'question.asked' && input && endpoint && properties.id && properties.sessionID) {
|
||||
if (pending.has(properties.id)) return
|
||||
let root = properties.sessionID
|
||||
const seen = new Set<string>()
|
||||
for (;;) {
|
||||
if (seen.has(root)) return
|
||||
seen.add(root)
|
||||
const session = await input.client.session.get({ path: { id: root }, throwOnError: true })
|
||||
if (session.data.id !== root) return
|
||||
if (!session.data.parentID) break
|
||||
root = session.data.parentID
|
||||
}
|
||||
const listed = await input.client._client.get({ url: '/question', throwOnError: true })
|
||||
if (closed || pending.has(properties.id) || !Array.isArray(listed.data) || !listed.data.some((item) =>
|
||||
item.id === properties.id && item.sessionID === properties.sessionID
|
||||
)) return
|
||||
pending.set(properties.id, { sessionId: properties.sessionID, root })
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'session/update', params: {
|
||||
sessionId: root, update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: properties.id,
|
||||
_meta: { goodbuddyQuestion: { question: properties, endpoint,
|
||||
childCallId: tasks.get(properties.sessionID)?.callId } }
|
||||
}
|
||||
}
|
||||
}) + '\n')
|
||||
return
|
||||
}
|
||||
const part = properties.part
|
||||
let owner = properties.sessionID ? tasks.get(properties.sessionID) : undefined
|
||||
const sessionId = properties.sessionID ?? part?.sessionID
|
||||
let owner = sessionId ? tasks.get(sessionId) : undefined
|
||||
if (
|
||||
!owner && event.type === 'message.part.updated' &&
|
||||
event.type === 'message.part.updated' &&
|
||||
part?.type === 'tool' && part.tool === 'task' &&
|
||||
part.callID && properties.sessionID
|
||||
part.callID && sessionId
|
||||
) {
|
||||
owner = { sessionId: properties.sessionID, callId: part.callID }
|
||||
owner ??= { sessionId, callId: part.callID }
|
||||
const child = part.state?.metadata?.sessionId
|
||||
if (child) tasks.set(child, owner)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { chmodSync, mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import type { Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { ControllerRegistry, type ControllerLease } from './controller-registry'
|
||||
import { EventJournal } from './event-journal'
|
||||
import { AgentProtocolServer } from './protocol-server'
|
||||
import { WorkspacePathAccess } from './workspace-path-access'
|
||||
import { remoteWorkspaceReadTextRequestSchema } from '../shared/remote-agent-contracts'
|
||||
|
||||
const temporaryPaths: string[] = []
|
||||
const closeMethod = 'channel/close'
|
||||
@@ -341,6 +343,51 @@ describe('AgentProtocolServer connection bounds', () => {
|
||||
harness.close()
|
||||
})
|
||||
|
||||
it('preserves ENOENT from a real workspace missing-file read', async () => {
|
||||
const root = temporaryDirectory()
|
||||
const metadata = statSync(root)
|
||||
const workspace = new WorkspacePathAccess({
|
||||
canonicalPath: root,
|
||||
device: metadata.dev.toString(),
|
||||
inode: metadata.ino.toString(),
|
||||
workspaceIdentity: 'workspace-test'
|
||||
})
|
||||
const harness = createHarness({
|
||||
methods: {
|
||||
'workspace/readText': (params) =>
|
||||
workspace.readText(remoteWorkspaceReadTextRequestSchema.parse(params))
|
||||
}
|
||||
})
|
||||
try {
|
||||
harness.socket.receive(
|
||||
controlFrame(harness.controller, 'missing-file', '1', 1, {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'workspace/readText',
|
||||
params: {
|
||||
workspaceId: 'workspace-test',
|
||||
generation: 1,
|
||||
relativePath: 'missing.txt',
|
||||
offsetBytes: 0,
|
||||
maximumBytes: 100
|
||||
}
|
||||
})
|
||||
)
|
||||
await waitFor(() => harness.socket.writes.length === 2)
|
||||
expect(jsonPayload(decodeAgentFrame(harness.socket.writes[0]!))).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: expect.stringContaining('ENOENT'),
|
||||
data: { code: 'ENOENT' }
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
harness.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns strict status, doctor, and exact implemented capabilities', async () => {
|
||||
const methods = Object.fromEntries(
|
||||
[
|
||||
|
||||
@@ -1137,7 +1137,7 @@ function typedErrorCode(error: unknown): string | undefined {
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
typeof error.code !== 'string' ||
|
||||
!/^[a-z][a-z0-9-]{0,63}$/u.test(error.code)
|
||||
!/^(?:[a-z][a-z0-9-]{0,63}|E[A-Z0-9]{1,63})$/u.test(error.code)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
|
||||
import { SemanticPromptStore } from './semantic-prompt-store'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
import { createOpenCodeModelBridgeProviderConfig } from './model-bridge-helper'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
|
||||
const binary = process.env.GOODBUDDY_TEST_OPENCODE_BINARY ?? join(process.cwd(),
|
||||
'.runtime-resources', process.arch, process.platform === 'win32' ? 'opencode.exe' : 'opencode')
|
||||
|
||||
it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as const)(
|
||||
'round trips native ACP %s questions through the production plugin and Agent owner', async (scenario) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-question-'))
|
||||
await mkdir(join(root, 'state'))
|
||||
const pluginPath = join(root, 'plugin.mjs')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource())
|
||||
let calls = 0
|
||||
let answered = false
|
||||
let tools: string[] = []
|
||||
const model = createServer((request, response) => {
|
||||
void (async () => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk.toString()
|
||||
const input = JSON.parse(body)
|
||||
tools = (input.tools ?? []).map((tool: { function: { name: string } }) => tool.function.name)
|
||||
if (++calls > 4) { response.writeHead(500).end(); return }
|
||||
const messages = input.messages as Array<{ role: string; content: unknown; tool_call_id?: string }>
|
||||
const results = messages.filter(message => message.role === 'tool')
|
||||
answered ||= JSON.stringify(results).includes('REMOTE_ANSWER') ||
|
||||
(scenario === 'reject' && JSON.stringify(results).includes('dismissed'))
|
||||
const childPrompt = JSON.stringify(messages.filter(message => message.role === 'user').at(-1)).includes('CHILD_DECISION')
|
||||
const task = scenario === 'child' && !childPrompt && results.length === 0
|
||||
const finished = results.length > 0
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const [delta, finish] of [
|
||||
[finished ? { role: 'assistant', content: 'QUESTION_OK' } : {
|
||||
role: 'assistant', tool_calls: [{ index: 0, id: task ? 'native-task' : 'native-question', type: 'function',
|
||||
function: { name: task ? 'task' : 'question', arguments: JSON.stringify(task ? {
|
||||
subagent_type: 'general', description: 'Ask child question', prompt: 'CHILD_DECISION: Ask a question.'
|
||||
} : { questions: [
|
||||
{ header: 'Details', question: 'Remote decision?', options: [], custom: true }
|
||||
] }) } }]
|
||||
}, null], [{}, finished ? 'stop' : 'tool_calls']
|
||||
]) response.write(`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk',
|
||||
created: 1, model: 'fixture', choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`)
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => response.destroy())
|
||||
})
|
||||
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
|
||||
const address = model.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No fixture port')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({ protocol: 'openai-chat-completions',
|
||||
model: 'fixture', loopbackOrigin: `http://127.0.0.1:${address.port}/${'a'.repeat(43)}`, workMode: 'execute' })
|
||||
const child = spawn(binary, ['acp'], { cwd: root, stdio: 'pipe', env: {
|
||||
...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, plugin: [pathToFileURL(pluginPath).href] }),
|
||||
OPENCODE_CONFIG_DIR: join(process.cwd(), '.runtime-resources', 'opencode-config'),
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: 'true', OPENCODE_DISABLE_MODELS_FETCH: 'true',
|
||||
OPENCODE_ENABLE_QUESTION_TOOL: 'true',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: 'true', XDG_DATA_HOME: join(root, 'data'),
|
||||
XDG_STATE_HOME: join(root, 'state'), XDG_CONFIG_HOME: join(root, 'config')
|
||||
} })
|
||||
let stderr = ''
|
||||
child.stderr.on('data', data => { stderr += data.toString() })
|
||||
const transcript = new SemanticPromptStore(join(root, 'state', 'prompts.sqlite'))
|
||||
transcript.prepare({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
|
||||
controllerId: 'controller', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
|
||||
let complete = false
|
||||
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding', controllerId: 'controller',
|
||||
workspaceDirectory: root, workMode: 'execute', transcript,
|
||||
process: {
|
||||
writeStdin: async (data: Uint8Array) => { child.stdin.write(data) },
|
||||
subscribeOutput: (listener: (event: { stream: string; data: Uint8Array }) => void) => {
|
||||
const onData = (data: Uint8Array) => listener({ stream: 'stdout', data })
|
||||
child.stdout.on('data', onData)
|
||||
return () => { child.stdout.off('data', onData) }
|
||||
},
|
||||
subscribeExit: (listener: () => void) => { child.on('exit', listener); return () => { child.off('exit', listener) } }
|
||||
} as unknown as RuntimeAcpProcessOwner,
|
||||
completePrompt: () => { complete = true }
|
||||
})
|
||||
try {
|
||||
await owner.start({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
|
||||
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] })
|
||||
let questionId: string | undefined
|
||||
await expect.poll(async () => {
|
||||
const page = transcript.page({ bindingId: 'binding', operationId: 'operation',
|
||||
controllerId: 'controller', afterSequence: '0', limit: 100 })
|
||||
for (const event of page.events) {
|
||||
const notification = event.payload as { update?: { _meta?: { goodbuddyQuestion?: {
|
||||
question: { id: string }; endpoint?: string; childCallId?: string
|
||||
} } } }
|
||||
const extension = notification.update?._meta?.goodbuddyQuestion
|
||||
if (extension && !questionId) {
|
||||
expect(extension.endpoint).toBeUndefined()
|
||||
if (scenario === 'child') expect(extension.childCallId).toBe('native-task')
|
||||
questionId = extension.question.id
|
||||
expect(owner.pendingQuestions('other')).toEqual([])
|
||||
expect(owner.pendingQuestions('operation')).toHaveLength(1)
|
||||
await expect(owner.respondToQuestion({ bindingId: 'other', operationId: 'operation', questionId,
|
||||
answers: [['WRONG']] })).rejects.toThrow('no longer pending')
|
||||
if (scenario === 'cancel') {
|
||||
await owner.cancel()
|
||||
expect(owner.pendingQuestions('operation')).toEqual([])
|
||||
continue
|
||||
}
|
||||
await owner.respondToQuestion({ bindingId: 'binding', operationId: 'operation', questionId,
|
||||
answers: scenario === 'reject' ? [] : [['REMOTE_ANSWER']] })
|
||||
}
|
||||
}
|
||||
return complete
|
||||
}, { timeout: 30_000, interval: 50 }).toBe(true)
|
||||
expect(tools).toContain('question')
|
||||
expect(questionId, stderr).toBeDefined()
|
||||
if (scenario === 'reject') {
|
||||
expect(JSON.stringify(transcript.page({ bindingId: 'binding', operationId: 'operation',
|
||||
controllerId: 'controller', afterSequence: '0', limit: 100 }).events)).toContain('dismissed')
|
||||
} else if (scenario !== 'cancel') expect(answered).toBe(true)
|
||||
expect(owner.pendingQuestions('operation')).toEqual([])
|
||||
expect(calls).toBe(scenario === 'child' ? 4 : scenario === 'reject' || scenario === 'cancel' ? 1 : 2)
|
||||
await expect(owner.respondToQuestion({ bindingId: 'binding', operationId: 'operation',
|
||||
questionId: questionId!, answers: [['LATE']] })).rejects.toThrow('no longer pending')
|
||||
} catch (error) {
|
||||
console.error({ calls, tools, stderr, events: transcript.page({ bindingId: 'binding',
|
||||
operationId: 'operation', controllerId: 'controller', afterSequence: '0', limit: 100 }).events })
|
||||
throw error
|
||||
} finally {
|
||||
owner.close()
|
||||
child.kill()
|
||||
await new Promise<void>(resolve => { if (child.exitCode !== null) resolve(); else child.once('exit', () => resolve()) })
|
||||
transcript.close()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>(resolve => model.close(() => resolve()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { remoteQuestionResponseSchema } from '../shared/remote-question-contracts'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
@@ -421,6 +422,20 @@ export class RuntimeAcpBackend {
|
||||
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)),
|
||||
'runtime/attachPrompt': (params, context) =>
|
||||
this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)),
|
||||
'runtime/respondToQuestion': (params, context) =>
|
||||
this.#enqueuePublicControl(async () => {
|
||||
const request = remoteQuestionResponseSchema.parse(params)
|
||||
this.#assertControllerLive(context)
|
||||
const binding = this.#bindings.get(request.bindingId)
|
||||
if (!binding?.ownedAcp || binding.activeOperationId !== request.operationId ||
|
||||
binding.controllerId !== context.controller.controllerId ||
|
||||
binding.controllerGeneration !== context.controller.generation ||
|
||||
binding.connectionId !== context.controller.connectionId) {
|
||||
throw new RuntimeAcpBackendError('Question reply identity does not match', 'identity')
|
||||
}
|
||||
await binding.ownedAcp.respondToQuestion(request)
|
||||
return {}
|
||||
}),
|
||||
'runtime/pagePromptTranscript': (params, context) =>
|
||||
this.#enqueuePublicControl(() => this.#pageOwnedPrompt(params, context)),
|
||||
'runtime/ackPromptTranscript': (params, context) =>
|
||||
@@ -1476,10 +1491,12 @@ export class RuntimeAcpBackend {
|
||||
'process'
|
||||
)
|
||||
}
|
||||
return this.#options.semanticPrompts.page({
|
||||
const page = this.#options.semanticPrompts.page({
|
||||
...request,
|
||||
controllerId: context.controller.controllerId
|
||||
})
|
||||
const binding = this.#bindings.get(request.bindingId)
|
||||
return { ...page, pendingQuestions: binding?.ownedAcp?.pendingQuestions(request.operationId) ?? [] }
|
||||
}
|
||||
|
||||
#ackOwnedPrompt(
|
||||
|
||||
@@ -170,6 +170,7 @@ describe('production Runtime composition', () => {
|
||||
'runtime/preparePrompt',
|
||||
'runtime/reconcilePrompt',
|
||||
'runtime/replayAcpChannel',
|
||||
'runtime/respondToQuestion',
|
||||
'runtime/resumeAcpChannel',
|
||||
'runtime/startPrompt'
|
||||
])
|
||||
|
||||
@@ -2872,6 +2872,82 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
).not.toHaveProperty('modelBridge')
|
||||
})
|
||||
|
||||
it('restores live questions after transcript ACK, scopes replies and ignores answered history', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const reply = vi.fn<NonNullable<RemoteRuntimeChannel['respondToQuestion']>>()
|
||||
.mockRejectedValueOnce(new Error('temporary response failure'))
|
||||
.mockResolvedValue(undefined)
|
||||
fixture.channel.respondToQuestion = reply
|
||||
let answered = false
|
||||
let pages = 0
|
||||
const question = { sessionId: 'owned-session', update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'native-question', _meta: {
|
||||
goodbuddyQuestion: { question: { id: 'native-question', sessionID: 'owned-session',
|
||||
questions: [{ header: 'Input', question: 'Which?', options: [] }] } }
|
||||
}
|
||||
} }
|
||||
fixture.channel.pageOwnedPromptTranscript = async ({ bindingId, operationId }) => ({
|
||||
bindingId, operationId, state: answered ? 'completed' : 'running', sessionId: 'owned-session',
|
||||
latestSequence: answered ? '2' : '1', acknowledgedSequence: '1', hasMore: false,
|
||||
pendingQuestions: answered ? [] : [question],
|
||||
events: answered ? [
|
||||
{ sequence: '1', kind: 'session-update', payload: question, createdAt: 1 },
|
||||
{ sequence: '2', kind: 'prompt-terminal', payload: { status: 'completed', response: { stopReason: 'end_turn' } }, createdAt: 2 }
|
||||
] : (++pages === 1 ? [] : [{ sequence: '1', kind: 'session-update', payload: question, createdAt: 1 }])
|
||||
})
|
||||
let id: string | undefined
|
||||
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
|
||||
if (event.type !== 'question') continue
|
||||
expect(id).toBeUndefined()
|
||||
id = event.questionId
|
||||
expect(id).not.toBe('native-question')
|
||||
expect(event.questions[0]).toMatchObject({ custom: true, multiple: false })
|
||||
await expect(fixture.instance.respondToQuestion(id, [['A']])).rejects.toThrow('temporary response failure')
|
||||
await fixture.instance.respondToQuestion(id, [['A']])
|
||||
answered = true
|
||||
}
|
||||
expect(id).toBeDefined()
|
||||
expect(reply).toHaveBeenLastCalledWith({ bindingId: expect.any(String), operationId: request.requestId,
|
||||
questionId: 'native-question', answers: [['A']] })
|
||||
await expect(fixture.instance.respondToQuestion(id!, [['late']])).rejects.toThrow('no longer pending')
|
||||
expect(reply).toHaveBeenCalledTimes(2)
|
||||
await fixture.instance.dispose()
|
||||
})
|
||||
|
||||
it('drops reply mappings when the Agent resolves a question while the prompt remains active', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const reply = vi.fn(async () => undefined)
|
||||
fixture.channel.respondToQuestion = reply
|
||||
let pages = 0
|
||||
let answered = false
|
||||
fixture.channel.pageOwnedPromptTranscript = async ({ bindingId, operationId }) => ({
|
||||
bindingId, operationId, state: answered ? 'completed' : 'running', sessionId: 'owned-session',
|
||||
latestSequence: answered ? '1' : '0', acknowledgedSequence: '0', hasMore: false,
|
||||
pendingQuestions: answered ? [] : [{ sessionId: 'owned-session', update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'question', _meta: { goodbuddyQuestion: {
|
||||
question: { id: ++pages === 1 ? 'first' : 'second', sessionID: 'owned-session',
|
||||
questions: [{ header: 'Input', question: 'Which?', options: [] }] }
|
||||
} }
|
||||
} }],
|
||||
events: answered ? [{ sequence: '1', kind: 'prompt-terminal', createdAt: 1,
|
||||
payload: { status: 'completed', response: { stopReason: 'end_turn' } } }] : []
|
||||
})
|
||||
let previous: string | undefined
|
||||
const resolved: string[] = []
|
||||
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
|
||||
if (event.type === 'question-resolved') resolved.push(event.questionId)
|
||||
if (event.type !== 'question') continue
|
||||
if (!previous) { previous = event.questionId; continue }
|
||||
await expect(fixture.instance.respondToQuestion(previous, [['stale']])).rejects.toThrow('no longer pending')
|
||||
expect(reply).not.toHaveBeenCalled()
|
||||
await fixture.instance.respondToQuestion(event.questionId)
|
||||
answered = true
|
||||
}
|
||||
expect(reply).toHaveBeenCalledOnce()
|
||||
expect(resolved).toEqual([previous])
|
||||
await fixture.instance.dispose()
|
||||
})
|
||||
|
||||
it('freshly attaches an accepted prompt without resending it', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const first = fixture.instance.run(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { remoteQuestionSchema } from '../../shared/remote-question-contracts'
|
||||
import type { AgentQuestionAnswer } from '../../shared/contracts'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
@@ -137,6 +139,7 @@ export class RemotePromptRecoveryUnavailableError extends Error {
|
||||
}
|
||||
|
||||
type ActivePrompt = {
|
||||
reportedQuestions?: Set<string>
|
||||
subagentProgress?: OpenCodeSubagentProgress
|
||||
requestId: string
|
||||
operationId: string
|
||||
@@ -352,6 +355,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
Promise<SessionRecord>
|
||||
>()
|
||||
private readonly activePrompts = new Map<string, ActivePrompt>()
|
||||
private readonly pendingQuestions = new Map<string, { prompt: ActivePrompt; nativeId: string }>()
|
||||
private readonly sessionReservations = new Set<string>()
|
||||
private readonly drainWaiters = new Set<() => void>()
|
||||
private readonly contexts = new Map<string, ChannelContext>()
|
||||
@@ -2070,6 +2074,21 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
prompt: ActivePrompt,
|
||||
update: SessionUpdate
|
||||
): RuntimePublicEvent | undefined {
|
||||
const extension = update._meta?.goodbuddyQuestion
|
||||
if (this.options.runtimeId === 'opencode' && extension && typeof extension === 'object' &&
|
||||
prompt.open && prompt.context.channel.respondToQuestion) {
|
||||
const value = extension as { question?: unknown; childCallId?: unknown }
|
||||
const question = remoteQuestionSchema.parse(value.question)
|
||||
const questionId = JSON.stringify([prompt.bindingId, prompt.operationId, question.id])
|
||||
prompt.reportedQuestions ??= new Set()
|
||||
if (prompt.reportedQuestions.has(questionId)) return undefined
|
||||
prompt.reportedQuestions.add(questionId)
|
||||
this.pendingQuestions.set(questionId, { prompt, nativeId: question.id })
|
||||
const childTaskId = typeof value.childCallId === 'string'
|
||||
? prompt.toolCalls.get(value.childCallId)?.retainedSubagent?.childTaskId : undefined
|
||||
return { requestId: prompt.requestId, type: 'question', questionId,
|
||||
questions: question.questions, ...(childTaskId ? { childTaskId } : {}) }
|
||||
}
|
||||
if (this.options.runtimeId === 'opencode') {
|
||||
if (!prompt.subagentProgress) {
|
||||
prompt.subagentProgress = new OpenCodeSubagentProgress(
|
||||
@@ -2295,6 +2314,25 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return undefined
|
||||
}
|
||||
|
||||
async respondToQuestion(questionId: string, answers: AgentQuestionAnswer[] = []): Promise<void> {
|
||||
const pending = this.pendingQuestions.get(questionId)
|
||||
if (!pending || !pending.prompt.open || !pending.prompt.context.channel.respondToQuestion) {
|
||||
throw new Error('Remote question is no longer pending')
|
||||
}
|
||||
const { prompt, nativeId } = pending
|
||||
this.assertUsable(prompt.context, true)
|
||||
await this.awaitOperation(prompt.context, '回答问题', prompt.context.channel.respondToQuestion!({
|
||||
bindingId: prompt.bindingId, operationId: prompt.operationId, questionId: nativeId, answers
|
||||
}), undefined, false)
|
||||
this.pendingQuestions.delete(questionId)
|
||||
}
|
||||
|
||||
private clearPromptQuestions(prompt: ActivePrompt): void {
|
||||
for (const [id, pending] of this.pendingQuestions) {
|
||||
if (pending.prompt === prompt) this.pendingQuestions.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private withRemoteProvenance(
|
||||
event: RuntimePublicEvent,
|
||||
bindingId: string,
|
||||
@@ -2553,7 +2591,8 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
transcriptEvent.payload,
|
||||
session.sessionId
|
||||
)
|
||||
const mapped = this.mapUpdate(prompt, notification.update)
|
||||
const mapped = page.pendingQuestions !== undefined && notification.update._meta?.goodbuddyQuestion
|
||||
? undefined : this.mapUpdate(prompt, notification.update)
|
||||
if (mapped !== undefined) {
|
||||
publicEvents.push(this.limitEvent(mapped))
|
||||
}
|
||||
@@ -2672,6 +2711,23 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
transcriptPollDelayMs = 100
|
||||
continue
|
||||
}
|
||||
const pendingNotifications = (page.pendingQuestions ?? []).map(question =>
|
||||
transcriptSessionNotification(question, session.sessionId))
|
||||
if (page.pendingQuestions !== undefined) {
|
||||
const liveIds = new Set(pendingNotifications.map(notification => remoteQuestionSchema.parse(
|
||||
(notification.update._meta?.goodbuddyQuestion as { question?: unknown } | undefined)?.question
|
||||
).id))
|
||||
for (const [id, pending] of this.pendingQuestions) {
|
||||
if (pending.prompt === prompt && !liveIds.has(pending.nativeId)) {
|
||||
this.pendingQuestions.delete(id)
|
||||
yield { requestId: prompt.requestId, type: 'question-resolved', questionId: id }
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const notification of pendingNotifications) {
|
||||
const event = this.mapUpdate(prompt, notification.update)
|
||||
if (event?.type === 'question') yield event
|
||||
}
|
||||
if (
|
||||
page.state === 'completed' ||
|
||||
page.state === 'failed' ||
|
||||
@@ -2699,6 +2755,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
}
|
||||
signal.removeEventListener('abort', cancel)
|
||||
prompt.open = false
|
||||
this.clearPromptQuestions(prompt)
|
||||
this.activePrompts.delete(binding.bindingId)
|
||||
}
|
||||
}
|
||||
@@ -3043,6 +3100,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
session.binding = binding
|
||||
}
|
||||
prompt.open = false
|
||||
this.clearPromptQuestions(prompt)
|
||||
prompt.interrupt = undefined
|
||||
prompt.wake?.()
|
||||
prompt.wake = undefined
|
||||
@@ -3251,6 +3309,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
new Error('远端 Runtime 已关闭,活动请求结果需要核对')
|
||||
)
|
||||
prompt.open = false
|
||||
this.clearPromptQuestions(prompt)
|
||||
prompt.wake?.()
|
||||
this.discardUpdates(prompt)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { execFile, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { rgPath } from '@vscode/ripgrep'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LocalWorkspaceAccess } from '../workspace'
|
||||
import { searchWorkspaceWithRipgrep, type WorkspaceRipgrepInput } from './direct-model-ripgrep'
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>()
|
||||
const mocked = { ...actual, execFile: vi.fn(actual.execFile) }
|
||||
return { ...mocked, default: mocked }
|
||||
})
|
||||
|
||||
const input: WorkspaceRipgrepInput = {
|
||||
path: '.', pattern: 'target', glob: [], fixedStrings: false,
|
||||
ignoreCase: false, filesOnly: false, maxResults: 100
|
||||
}
|
||||
const match = JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: './valid.txt' }, lines: { text: 'target\n' },
|
||||
line_number: 1, submatches: [{ start: 0 }]
|
||||
}
|
||||
}) + '\n'
|
||||
const accessError = 'blocked: Permission denied (os error 13)'
|
||||
const directories: string[] = []
|
||||
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process')
|
||||
|
||||
function mockResult(code: number | string, stdout: string, stderr: string) {
|
||||
const error = Object.assign(new Error(`process failed: ${code}`), { code })
|
||||
vi.mocked(execFile).mockImplementation((file, args, options, callback) => {
|
||||
if (file !== rgPath) return actual.execFile(file, args, options, callback)
|
||||
const complete = callback as (error: Error, stdout: string, stderr: string) => void
|
||||
complete(error, stdout, stderr)
|
||||
return {} as ChildProcess
|
||||
})
|
||||
return error
|
||||
}
|
||||
|
||||
function search(overrides: Partial<WorkspaceRipgrepInput> = {}, signal = new AbortController().signal) {
|
||||
return searchWorkspaceWithRipgrep(
|
||||
rgPath, { ...input, ...overrides }, new LocalWorkspaceAccess(process.cwd()), signal
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.mocked(execFile).mockReset()
|
||||
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('searchWorkspaceWithRipgrep', () => {
|
||||
it('preserves formatted matches with bounded traversal diagnostics and an explicit warning', async () => {
|
||||
mockResult(2, match, accessError + '\n' + 'x'.repeat(10_000))
|
||||
const result = await search()
|
||||
expect(vi.mocked(execFile).mock.calls.map((call) => call[0])).toContain(rgPath)
|
||||
expect(result).toContain('valid.txt:1:1:target\\n')
|
||||
expect(result).toContain('incomplete search coverage')
|
||||
expect(result).toContain('missing results do not establish absence')
|
||||
expect(result).toContain(accessError)
|
||||
expect(result.length).toBeLessThan(2_500)
|
||||
})
|
||||
|
||||
it('preserves file listings and existing result truncation', async () => {
|
||||
mockResult(2, './valid.txt\0./other.txt\0', accessError)
|
||||
const result = await search({ filesOnly: true, maxResults: 1 })
|
||||
expect(result).toContain('incomplete search coverage')
|
||||
expect(result).toContain('\nvalid.txt\n...[truncated after 1 files]')
|
||||
expect(result).not.toContain('other.txt')
|
||||
})
|
||||
|
||||
it('qualifies a no-match summary when traversal was incomplete', async () => {
|
||||
mockResult(2, JSON.stringify({ type: 'summary', data: {} }) + '\n', accessError)
|
||||
const result = await search()
|
||||
expect(result).toContain('incomplete search coverage')
|
||||
expect(result).toContain('[no matches found in searched files]')
|
||||
expect(result).not.toContain('[no matches found]')
|
||||
})
|
||||
|
||||
it.each([false, true])('throws detailed errors with no stdout (filesOnly=%s)', async (filesOnly) => {
|
||||
mockResult(2, '', accessError)
|
||||
await expect(search({ filesOnly })).rejects.toThrow(accessError)
|
||||
})
|
||||
|
||||
it('does not turn invalid regex diagnostics into successful partial output', async () => {
|
||||
mockResult(2, match, 'regex parse error:\n [\nerror: unclosed character class')
|
||||
await expect(search({ pattern: '[' })).rejects.toThrow('unclosed character class')
|
||||
})
|
||||
|
||||
it('keeps launch failures as errors even with stdout', async () => {
|
||||
const error = mockResult('ENOENT', match, '')
|
||||
await expect(search()).rejects.toMatchObject({ cause: error })
|
||||
})
|
||||
|
||||
it('preserves the cancellation reason even when partial stdout is available', async () => {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel search')
|
||||
controller.abort(reason)
|
||||
mockResult(2, match, accessError)
|
||||
await expect(search({}, controller.signal)).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it('keeps exit 1 as a normal no-match result', async () => {
|
||||
mockResult(1, '', '')
|
||||
await expect(search()).resolves.toBe('[no matches found]')
|
||||
})
|
||||
|
||||
it('preserves capture-limit truncation', async () => {
|
||||
mockResult('ERR_CHILD_PROCESS_STDIO_MAXBUFFER', match, '')
|
||||
await expect(search()).resolves.toBe('valid.txt:1:1:target\\n\n...[truncated after 1 matches]')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ filesOnly: false, pattern: 'target', expected: 'valid.txt:1:1:target\\n' },
|
||||
{ filesOnly: true, pattern: 'target', expected: '\nvalid.txt' },
|
||||
{ filesOnly: false, pattern: 'absent', expected: '[no matches found in searched files]' }
|
||||
])('handles real bundled rg with a valid file plus a missing target ($filesOnly, $pattern)', async ({ filesOnly, pattern, expected }) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ripgrep-'))
|
||||
directories.push(directory)
|
||||
await writeFile(join(directory, 'valid.txt'), 'target\n')
|
||||
// Add a second target at the process boundary: the public API stats its single target first.
|
||||
vi.mocked(execFile).mockImplementation((file, args, options, callback) =>
|
||||
actual.execFile(file, file === rgPath ? [...args as string[], 'missing.txt'] : args, options, callback)
|
||||
)
|
||||
const result = await searchWorkspaceWithRipgrep(
|
||||
rgPath, { ...input, path: 'valid.txt', filesOnly, pattern },
|
||||
new LocalWorkspaceAccess(directory), new AbortController().signal
|
||||
)
|
||||
expect(result).toContain(expected)
|
||||
expect(result).toContain('incomplete search coverage (ripgrep exit code 2)')
|
||||
expect(result).toContain('missing.txt')
|
||||
})
|
||||
|
||||
it('rejects an invalid regex using the real bundled rg', async () => {
|
||||
await expect(search({ pattern: '[' })).rejects.toThrow(/regex parse error/u)
|
||||
})
|
||||
})
|
||||
@@ -52,6 +52,7 @@ function runRipgrep(
|
||||
if (
|
||||
processError &&
|
||||
processError.code !== 1 &&
|
||||
processError.code !== 2 &&
|
||||
processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
) {
|
||||
reject(
|
||||
@@ -225,7 +226,17 @@ export async function searchWorkspaceWithRipgrep(
|
||||
identity.canonicalDisplayPath,
|
||||
signal
|
||||
)
|
||||
return input.filesOnly
|
||||
const incomplete = result.code === 2
|
||||
if (
|
||||
incomplete &&
|
||||
(!result.stdout.trim() ||
|
||||
/regex parse error|error parsing (?:regex|glob)|PCRE2: error compiling/iu.test(result.stderr))
|
||||
) {
|
||||
throw new Error(
|
||||
`ripgrep failed (exit code 2); search coverage is incomplete.\n${result.stderr.trim().slice(0, 2_000) || 'No search output was available.'}`
|
||||
)
|
||||
}
|
||||
const formatted = input.filesOnly
|
||||
? formatFileResults(
|
||||
result.stdout,
|
||||
input.maxResults,
|
||||
@@ -236,4 +247,13 @@ export async function searchWorkspaceWithRipgrep(
|
||||
input.maxResults,
|
||||
result.truncated
|
||||
)
|
||||
if (incomplete) {
|
||||
const warning = '[warning: incomplete search coverage (ripgrep exit code 2). Some files or directories could not be searched; missing results do not establish absence.]'
|
||||
const details = result.stderr.trim().slice(0, 2_000)
|
||||
const partial = formatted
|
||||
.replace(/^\[no matches found\]$/u, '[no matches found in searched files]')
|
||||
.replace(/^\[no files found\]$/u, '[no files found in searched locations]')
|
||||
return `${warning}\n${details ? `${details}\n` : ''}${partial}`
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
@@ -29,32 +29,22 @@ const firstLibraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function createService() {
|
||||
const searchHybridMany = vi.fn(
|
||||
const retrieveMany = vi.fn(
|
||||
async (libraryIds: readonly string[]) =>
|
||||
libraryIds.map((knowledgeBaseId, index) => ({
|
||||
knowledgeBaseId,
|
||||
result: {
|
||||
document: {
|
||||
id: `33333333-3333-4333-8333-33333333333${index}`,
|
||||
title: `文档 ${index}`
|
||||
},
|
||||
source: {
|
||||
displayName: `来源 ${index}`,
|
||||
location: `/private/${index}`
|
||||
},
|
||||
chunk: {
|
||||
id: `44444444-4444-4444-8444-44444444444${index}`,
|
||||
location: `第 ${index + 1} 段`
|
||||
},
|
||||
response: { diagnostics: {}, results: [{
|
||||
knowledgeBaseId,
|
||||
documentId: `33333333-3333-4333-8333-33333333333${index}`,
|
||||
documentTitle: `文档 ${index}`,
|
||||
sourceDisplayName: `来源 ${index}`,
|
||||
chunkId: `44444444-4444-4444-8444-444444444440`,
|
||||
location: `第 ${index + 1} 段`,
|
||||
snippet: `<mark>匹配</mark> ${index}`,
|
||||
rank: index + 1,
|
||||
retrieval: {
|
||||
score: 0.5,
|
||||
channels: ['fts'] as const,
|
||||
lexicalRank: 1,
|
||||
evidenceIds: []
|
||||
}
|
||||
}
|
||||
channels: ['fts'] as const,
|
||||
scores: { fusedScore: 0.5, ftsRank: 1 }
|
||||
}] }
|
||||
}))
|
||||
)
|
||||
const service = {
|
||||
@@ -72,9 +62,9 @@ function createService() {
|
||||
}
|
||||
]
|
||||
},
|
||||
searchHybridMany
|
||||
retrieveMany
|
||||
} as unknown as KnowledgeService
|
||||
return { service, searchHybridMany }
|
||||
return { service, retrieveMany }
|
||||
}
|
||||
|
||||
function customMcpServer(
|
||||
@@ -189,24 +179,19 @@ function testTool(name: string): Tool {
|
||||
const gateways: KnowledgeMcpGateway[] = []
|
||||
|
||||
it('honors a requested knowledge result count above eight', async () => {
|
||||
const { service, searchHybridMany } = createService()
|
||||
const [sample] = await searchHybridMany([firstLibraryId])
|
||||
searchHybridMany.mockClear()
|
||||
searchHybridMany.mockResolvedValue(Array.from({ length: 12 }, (_, index) => ({
|
||||
...sample!,
|
||||
result: {
|
||||
...sample!.result,
|
||||
chunk: { ...sample!.result.chunk, id: crypto.randomUUID() },
|
||||
rank: index + 1
|
||||
}
|
||||
})))
|
||||
const { service, retrieveMany } = createService()
|
||||
const [sample] = await retrieveMany([firstLibraryId])
|
||||
retrieveMany.mockClear()
|
||||
retrieveMany.mockResolvedValue([{ ...sample!, response: { diagnostics: {}, results: Array.from({ length: 12 }, (_, index) => ({
|
||||
...sample!.response.results[0]!, chunkId: crypto.randomUUID(), rank: index + 1
|
||||
})) } }])
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
gateways.push(gateway)
|
||||
const controller = new AbortController()
|
||||
const token = gateway.grant('large-search', [firstLibraryId], controller.signal)!
|
||||
const references = await gateway.search(token, { query: 'evidence', limit: 12 })
|
||||
expect(references).toHaveLength(12)
|
||||
expect(searchHybridMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', 12, controller.signal)
|
||||
expect(retrieveMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', expect.any(AbortSignal))
|
||||
await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow()
|
||||
})
|
||||
const databases: AssistantDatabase[] = []
|
||||
@@ -621,7 +606,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
})
|
||||
|
||||
it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => {
|
||||
const { service, searchHybridMany } = createService()
|
||||
const { service, retrieveMany } = createService()
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
gateways.push(gateway)
|
||||
const token = gateway.grant(
|
||||
@@ -652,10 +637,9 @@ describe('KnowledgeMcpGateway', () => {
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(searchHybridMany).toHaveBeenCalledWith(
|
||||
expect(retrieveMany).toHaveBeenCalledWith(
|
||||
[secondLibraryId],
|
||||
'要找什么',
|
||||
1,
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(references).toEqual([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference'
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
@@ -24,7 +25,6 @@ import type {
|
||||
BrowserTabSummary,
|
||||
KnowledgeSearchReference
|
||||
} from '../../shared/contracts'
|
||||
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
||||
import {
|
||||
knowledgeToolNames,
|
||||
knowledgeScopedDataToolCatalog,
|
||||
@@ -273,13 +273,7 @@ function textContent(value: string): MagicNoteRichContent {
|
||||
}
|
||||
|
||||
function referenceKey(reference: KnowledgeSearchReference): string {
|
||||
return [
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? '',
|
||||
reference.locator ?? '',
|
||||
reference.snippet
|
||||
].join('\0')
|
||||
return knowledgeReferenceKey(reference)
|
||||
}
|
||||
|
||||
function ensureBoundedCustomMcpResult(result: unknown): CallToolResult {
|
||||
@@ -681,20 +675,22 @@ export class KnowledgeMcpGateway {
|
||||
const { query, limit } = knowledgeSearchTool.inputSchema.parse(
|
||||
input
|
||||
)
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([signal, capability.signal])
|
||||
: capability.signal
|
||||
const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
|
||||
effectiveSignal.throwIfAborted()
|
||||
const libraries = this.knowledgeService.database.listKnowledgeBases(500)
|
||||
const libraryNames = new Map(
|
||||
libraries.map((library) => [library.id, library.name])
|
||||
)
|
||||
const results = await this.knowledgeService.searchHybridMany(
|
||||
const outcomes = await this.knowledgeService.retrieveMany(
|
||||
capability.libraryIds,
|
||||
query,
|
||||
limit,
|
||||
effectiveSignal
|
||||
)
|
||||
effectiveSignal.throwIfAborted()
|
||||
const warnings = outcomes.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
|
||||
if (outcomes.length && warnings.length === outcomes.length) throw new Error(warnings.join('; '))
|
||||
const results = outcomes.flatMap(item => item.response.results.map(result => ({ knowledgeBaseId: item.knowledgeBaseId, result })))
|
||||
if (!results.length && warnings.length) throw new Error(warnings.join('; '))
|
||||
const references: KnowledgeSearchReference[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const { knowledgeBaseId, result } of results.sort(
|
||||
@@ -704,22 +700,8 @@ export class KnowledgeMcpGateway {
|
||||
break
|
||||
}
|
||||
const reference: KnowledgeSearchReference = {
|
||||
libraryId: knowledgeBaseId,
|
||||
libraryName: libraryNames.get(knowledgeBaseId) ?? '知识库',
|
||||
documentId: result.document.id,
|
||||
chunkId: result.chunk.id,
|
||||
documentName: result.document.title.slice(0, 500),
|
||||
sourceName: result.source.displayName.slice(0, 500),
|
||||
locator: result.chunk.location?.slice(0, 1_000),
|
||||
snippet: stripKnowledgeHighlightTags(result.snippet).slice(0, 12_000),
|
||||
rank: result.rank,
|
||||
score: result.retrieval.score,
|
||||
lexicalRank: result.retrieval.lexicalRank,
|
||||
vectorRank: result.retrieval.vectorRank,
|
||||
graphRank: result.retrieval.graphRank,
|
||||
similarity: result.retrieval.similarity,
|
||||
retrievalChannels: result.retrieval.channels,
|
||||
evidenceIds: result.retrieval.evidenceIds?.slice(0, 100)
|
||||
...toKnowledgeReference(result, libraryNames.get(knowledgeBaseId) ?? '知识库'),
|
||||
...(warnings.length ? { warnings } : {})
|
||||
}
|
||||
const key = referenceKey(reference)
|
||||
if (seen.has(key)) {
|
||||
@@ -1473,13 +1455,14 @@ export class KnowledgeMcpGateway {
|
||||
private async callScopedTool(
|
||||
token: string,
|
||||
name: ScopedDataToolName,
|
||||
input: unknown
|
||||
input: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (name) {
|
||||
case 'knowledge_list':
|
||||
return { libraries: this.listLibraries(token, input) }
|
||||
case 'knowledge_search':
|
||||
return { references: await this.search(token, input) }
|
||||
return { references: await this.search(token, input, signal) }
|
||||
case 'note_list':
|
||||
return { notes: this.listMagicNotes(token, input) }
|
||||
case 'note_get':
|
||||
@@ -1641,7 +1624,8 @@ export class KnowledgeMcpGateway {
|
||||
await this.callScopedTool(
|
||||
token,
|
||||
name as ScopedDataToolName,
|
||||
parsedInput
|
||||
parsedInput,
|
||||
extra.signal
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4084,6 +4084,104 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('recovers an absolute workspace read by retrying a relative path against the real filesystem', async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-read-recovery-'))
|
||||
const workspaceAccess = new LocalWorkspaceAccess(workspace)
|
||||
const toolProvider = new ModelToolProvider(workspaceAccess, [])
|
||||
const absolutePath = join(workspace, 'README.md')
|
||||
const marker = `read-recovery-${crypto.randomUUID()}`
|
||||
const responses = [absolutePath, 'README.md'].map((path, index) => ({
|
||||
choices: [{ message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [{
|
||||
id: `read-${index}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'workspace_read_text',
|
||||
arguments: JSON.stringify({ path })
|
||||
}
|
||||
}]
|
||||
} }]
|
||||
}))
|
||||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||||
const response = responses.shift()
|
||||
return Response.json(response ?? {
|
||||
choices: [{ message: { role: 'assistant', content: 'Read completed.' } }]
|
||||
})
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'https://example.test/v1',
|
||||
model: 'test-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
toolProvider,
|
||||
workspaceAccess
|
||||
})
|
||||
const events: RuntimeEvent[] = []
|
||||
|
||||
try {
|
||||
await writeFile(absolutePath, `${marker}\n`)
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: 'conversation-workspace-read-recovery',
|
||||
prompt: 'Read README.md.',
|
||||
workMode: 'ask'
|
||||
}, new AbortController().signal, async () => 'deny')) {
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3)
|
||||
const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body)) as {
|
||||
messages: Array<{ role: string; tool_call_id?: string; content: string }>
|
||||
}
|
||||
const correction = secondBody.messages.filter((message) => message.role === 'tool')
|
||||
expect(correction).toHaveLength(1)
|
||||
expect(correction[0]).toMatchObject({ tool_call_id: 'read-0' })
|
||||
expect(JSON.parse(correction[0]!.content)).toEqual({
|
||||
ok: false,
|
||||
recoverable: true,
|
||||
error: expect.stringMatching(/^workspace_read_text: [\s\S]*路径必须相对于工作区/u),
|
||||
nextAction: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed. Do not retry identical arguments.'
|
||||
})
|
||||
expect(correction[0]!.content).not.toContain(marker)
|
||||
|
||||
const thirdBody = JSON.parse(String(fetcher.mock.calls[2]![1]!.body)) as {
|
||||
messages: Array<{ role: string; tool_call_id?: string; content: string }>
|
||||
}
|
||||
expect(thirdBody.messages.filter((message) => message.role === 'tool')).toEqual([
|
||||
correction[0],
|
||||
expect.objectContaining({
|
||||
tool_call_id: 'read-1',
|
||||
content: expect.stringContaining(marker)
|
||||
})
|
||||
])
|
||||
const toolEvents = events.filter((event) => event.type === 'tool')
|
||||
expect(toolEvents.map((event) => [event.callId, event.state])).toEqual([
|
||||
['read-0', 'pending'],
|
||||
['read-0', 'running'],
|
||||
['read-0', 'recoverable'],
|
||||
['read-1', 'pending'],
|
||||
['read-1', 'running'],
|
||||
['read-1', 'completed']
|
||||
])
|
||||
expect(JSON.parse(toolEvents[0]!.input!)).toEqual({ path: absolutePath })
|
||||
expect(toolEvents[2]).toMatchObject({
|
||||
error: expect.stringContaining('路径必须相对于工作区')
|
||||
})
|
||||
expect(JSON.parse(toolEvents[3]!.input!)).toEqual({ path: 'README.md' })
|
||||
expect(toolEvents[5]).toMatchObject({ output: expect.stringContaining(marker) })
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
type: 'text', delta: 'Read completed.'
|
||||
}))
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('streams OpenAI Responses text and reasoning through tool rounds', async () => {
|
||||
const streams = [
|
||||
createSseEventStream([
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock('../capabilities/mcp-client-transport', () => ({
|
||||
|
||||
import {
|
||||
ModelToolProvider,
|
||||
RecoverableModelToolError,
|
||||
type ModelToolCallContext,
|
||||
type ModelSubagentRequestContext,
|
||||
type ModelToolResult
|
||||
@@ -461,6 +462,158 @@ describe('ModelToolProvider', () => {
|
||||
).resolves.toBe('saved')
|
||||
})
|
||||
|
||||
it('recovers an absolute local path and reads the corrected relative path', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
await writeFile(join(workspace, 'note.txt'), 'corrected read')
|
||||
const provider = new ModelToolProvider(workspace)
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
await expect(provider.callTool('workspace_read_text', {
|
||||
path: join(workspace, 'note.txt')
|
||||
}, signal, toolContext)).rejects.toMatchObject({
|
||||
name: 'RecoverableModelToolError',
|
||||
nextAction: expect.stringContaining('workspace-relative path')
|
||||
})
|
||||
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext))
|
||||
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('corrected read') }] })
|
||||
await expect(provider.callTool('workspace_read_text', { path: 'missing.txt' }, signal, toolContext))
|
||||
.rejects.toMatchObject({
|
||||
name: 'RecoverableModelToolError', cause: { code: 'ENOENT' },
|
||||
nextAction: expect.stringContaining('Do not retry identical arguments')
|
||||
})
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers invalid search inputs and actual rg regex failures', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
await writeFile(join(workspace, 'note.txt'), '[literal')
|
||||
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
for (const args of [
|
||||
{ pattern: 'x', path: workspace }, { pattern: 'x', path: 'missing' },
|
||||
{ pattern: '[' }, { pattern: 'x', glob: ['['] }, { maxResults: 0 }
|
||||
]) {
|
||||
await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({
|
||||
name: 'RecoverableModelToolError',
|
||||
nextAction: expect.stringContaining('adjust the search path/globs')
|
||||
})
|
||||
}
|
||||
await expect(provider.callTool('workspace_rg', { pattern: '[', fixedStrings: true }, signal, toolContext))
|
||||
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('note.txt:1:1:') }] })
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds expected remote read errors without recovering disconnects or programmer errors', async () => {
|
||||
const access = new LocalWorkspaceAccess(await createWorkspace())
|
||||
const readText = vi.spyOn(access, 'readText')
|
||||
const provider = new ModelToolProvider(access)
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
for (const code of ['ENOENT', 'EACCES', 'invalid-path', 'invalid-utf8', 'special-file']) {
|
||||
const failure = Object.assign(new Error('detail'.repeat(1_000)), { data: { code } })
|
||||
readText.mockRejectedValueOnce(failure)
|
||||
const result = await provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext)
|
||||
.catch((error: unknown) => error)
|
||||
expect(result).toBeInstanceOf(RecoverableModelToolError)
|
||||
const error = result as RecoverableModelToolError
|
||||
expect(Buffer.byteLength(error.message)).toBeLessThanOrEqual(2_021)
|
||||
expect(error.cause).toBe(failure)
|
||||
expect(error.nextAction).not.toContain('process_execute')
|
||||
}
|
||||
for (const failure of [
|
||||
new TypeError('programmer error'), new Error('remote disconnected'),
|
||||
Object.assign(new Error('closed'), { code: 'closed' }),
|
||||
Object.assign(new Error('stale workspace'), { data: { code: 'stale-workspace' } })
|
||||
]) {
|
||||
readText.mockRejectedValueOnce(failure)
|
||||
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext))
|
||||
.rejects.toBe(failure)
|
||||
}
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers the wrapped rg exit code 2 but leaves a missing executable fatal', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
const access = new LocalWorkspaceAccess(workspace)
|
||||
const failure = new Error('regex parse error', {
|
||||
cause: Object.assign(new Error('process failed'), { code: 2 })
|
||||
})
|
||||
vi.spyOn(access, 'stat').mockRejectedValueOnce(failure)
|
||||
const provider = new ModelToolProvider(access, [], undefined, undefined, false, {
|
||||
ripgrepExecutablePath: join(workspace, 'missing-rg.exe')
|
||||
})
|
||||
try {
|
||||
await expect(provider.callTool('workspace_rg', { path: 'note.txt', pattern: '[' }, new AbortController().signal, toolContext))
|
||||
.rejects.toMatchObject({ name: 'RecoverableModelToolError', cause: failure })
|
||||
const error = await provider.callTool('workspace_rg', { pattern: 'x' }, new AbortController().signal, toolContext)
|
||||
.catch((error: unknown) => error)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).not.toBeInstanceOf(RecoverableModelToolError)
|
||||
expect(error).toMatchObject({ cause: { code: 'ENOENT' } })
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['workspace_read_text', 'workspace_rg'])('keeps %s cancellation fatal', async (name) => {
|
||||
const access = new LocalWorkspaceAccess(await createWorkspace())
|
||||
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
|
||||
const operation = name === 'workspace_rg' ? vi.spyOn(access, 'stat') : vi.spyOn(access, 'readText')
|
||||
const args = { path: 'note.txt', ...(name === 'workspace_rg' ? { pattern: 'x' } : {}) }
|
||||
try {
|
||||
const abortError = new DOMException('cancelled', 'AbortError')
|
||||
operation.mockRejectedValueOnce(abortError)
|
||||
await expect(provider.callTool(name, args, new AbortController().signal, toolContext)).rejects.toBe(abortError)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled during access')
|
||||
operation.mockImplementationOnce(async () => {
|
||||
controller.abort(reason)
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
})
|
||||
await expect(provider.callTool(name, args, controller.signal, toolContext)).rejects.toBe(reason)
|
||||
await expect(provider.callTool(name, {}, controller.signal, toolContext)).rejects.toBe(reason)
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('suggests process_execute only when available in local model Execute', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
const access = new LocalWorkspaceAccess(workspace)
|
||||
const identity = await access.getIdentity()
|
||||
const processService = {
|
||||
getCapability: vi.fn(async () => ({ available: true, shell: { kind: 'powershell', label: 'pwsh' } })),
|
||||
dispose: vi.fn(async () => {})
|
||||
} as unknown as DirectModelProcessService
|
||||
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { processService })
|
||||
try {
|
||||
for (const [context, available] of [
|
||||
[{ ...toolContext, runtimeTarget: 'model' as const }, true],
|
||||
[{ ...toolContext, runtimeTarget: 'model' as const, workMode: 'ask' as const }, false],
|
||||
[toolContext, false],
|
||||
[{ ...toolContext, runtimeTarget: 'model' as const, executionSpaceIdentity: `${identity.id}-other` }, false]
|
||||
] as const) {
|
||||
const error = await provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, context)
|
||||
.catch((error: unknown) => error) as RecoverableModelToolError
|
||||
expect(error).toBeInstanceOf(RecoverableModelToolError)
|
||||
expect(error.nextAction.includes('process_execute')).toBe(available)
|
||||
}
|
||||
vi.mocked(processService.getCapability).mockResolvedValueOnce({ available: false, reason: 'unavailable' })
|
||||
await expect(provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, {
|
||||
...toolContext, runtimeTarget: 'model'
|
||||
})).rejects.toMatchObject({ nextAction: expect.not.stringContaining('process_execute') })
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('delegates paged workspace reads and disposal to WorkspaceAccess', async () => {
|
||||
const readText = vi.fn(async () => ({
|
||||
path: 'remote.txt',
|
||||
|
||||
@@ -1731,25 +1731,74 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (name === 'workspace_rg') {
|
||||
if (!this.programming.ripgrepExecutablePath) {
|
||||
throw new Error('GoodBuddy 内置 ripgrep 不可用')
|
||||
}
|
||||
const input = ripgrepInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await searchWorkspaceWithRipgrep(
|
||||
this.programming.ripgrepExecutablePath,
|
||||
input,
|
||||
this.workspaceAccess,
|
||||
signal
|
||||
if (name === 'workspace_rg' || name === 'workspace_read_text') {
|
||||
try {
|
||||
if (name === 'workspace_rg') {
|
||||
if (!this.programming.ripgrepExecutablePath) {
|
||||
throw new Error('GoodBuddy 内置 ripgrep 不可用')
|
||||
}
|
||||
const input = ripgrepInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await searchWorkspaceWithRipgrep(
|
||||
this.programming.ripgrepExecutablePath,
|
||||
input,
|
||||
this.workspaceAccess,
|
||||
signal
|
||||
)
|
||||
)
|
||||
}
|
||||
const input = readInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await readWorkspaceLines(this.workspaceAccess, input, signal)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (name === 'workspace_read_text') {
|
||||
const input = readInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await readWorkspaceLines(this.workspaceAccess, input, signal)
|
||||
)
|
||||
} catch (error) {
|
||||
signal.throwIfAborted()
|
||||
if (!(error instanceof Error) || error.name === 'AbortError' ||
|
||||
(error.cause instanceof Error && error.cause.name === 'AbortError')) {
|
||||
throw error
|
||||
}
|
||||
const code = 'code' in error ? error.code : undefined
|
||||
const data = 'data' in error ? error.data : undefined
|
||||
const remoteCode = data && typeof data === 'object' && 'code' in data
|
||||
? data.code : undefined
|
||||
const expectedOsCodes = ['ENOENT', 'ENOTDIR', 'EISDIR', 'EACCES', 'EPERM', 'ELOOP', 'ENAMETOOLONG']
|
||||
const expectedWorkspaceError = [
|
||||
'路径必须是工作区内的相对路径',
|
||||
'路径必须是工作区内的相对路径,不能超出工作区',
|
||||
'文件路径不能超出项目工作区',
|
||||
'文件路径不能通过符号链接超出项目工作区',
|
||||
'目标不是普通文件',
|
||||
'目标不是目录',
|
||||
'工作区读取目标不是有效 UTF-8 文本',
|
||||
'工作区读取范围无效'
|
||||
].includes(error.message)
|
||||
const rgExecutionError = name === 'workspace_rg' && (
|
||||
(error.cause instanceof Error && 'code' in error.cause && error.cause.code === 2) ||
|
||||
error.message.startsWith('ripgrep failed (exit code 2); search coverage is incomplete.\n')
|
||||
)
|
||||
if (!(error instanceof z.ZodError) &&
|
||||
!(typeof code === 'string' && expectedOsCodes.includes(code)) &&
|
||||
!(typeof remoteCode === 'string' && [
|
||||
...expectedOsCodes, 'invalid-path', 'invalid-utf8',
|
||||
'not-directory', 'special-file', 'symlink-rejected'
|
||||
].includes(remoteCode)) &&
|
||||
!expectedWorkspaceError && !rgExecutionError) {
|
||||
throw error
|
||||
}
|
||||
let nextAction = name === 'workspace_rg'
|
||||
? 'Use workspace-relative paths; adjust the search path/globs or correct the pattern (use fixedStrings for literal text).'
|
||||
: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed.'
|
||||
nextAction += ' Do not retry identical arguments.'
|
||||
if ((await this.getProcessTool(context, signal)).length > 0) {
|
||||
nextAction += ' For files outside the workspace, use process_execute in local Execute mode.'
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
throw new RecoverableModelToolError(
|
||||
`${name}: ${utf8Prefix(error.message, 2_000)}`,
|
||||
nextAction,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (name === 'workspace_apply_patch') {
|
||||
const input = applyPatchInputSchema.parse(argumentsValue)
|
||||
|
||||
@@ -14,7 +14,7 @@ const cachedBinary = join(
|
||||
const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'completes native Task external reads and structured yes/no and text questions while Ask disables tools',
|
||||
'completes native Task external reads and concurrent structured questions while Ask disables tools',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-permissions-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
@@ -50,7 +50,8 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
tool = { id: 'external-read', name: 'read', arguments: { filePath: externalFile } }
|
||||
}
|
||||
} else if (results.some(result => result.tool_call_id === 'native-question')) {
|
||||
answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer')
|
||||
answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer') &&
|
||||
results.some(result => result.tool_call_id === 'parallel-question' && JSON.stringify(result.content).includes('Parallel answer'))
|
||||
content = 'PARENT_OK'
|
||||
} else if (results.some(result => result.tool_call_id === 'native-task')) {
|
||||
tool = { id: 'native-question', name: 'question', arguments: { questions: [
|
||||
@@ -74,7 +75,11 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
role: 'assistant',
|
||||
tool_calls: [{ index: 0, id: tool.id, type: 'function', function: {
|
||||
name: tool.name, arguments: JSON.stringify(tool.arguments)
|
||||
} }]
|
||||
} }, ...(tool.id === 'native-question' ? [{ index: 1, id: 'parallel-question', type: 'function', function: {
|
||||
name: 'question', arguments: JSON.stringify({ questions: [
|
||||
{ header: 'Parallel', question: 'Another decision?', options: [], custom: true }
|
||||
] })
|
||||
} }] : [])]
|
||||
} : { role: 'assistant', content }, null],
|
||||
[{}, tool ? 'tool_calls' : 'stop']
|
||||
]) {
|
||||
@@ -102,17 +107,24 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
let text = ''
|
||||
const pending: Array<{ id: string; answers: string[][] }> = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
|
||||
workMode: mode, prompt: mode === 'execute' ? 'USE_NATIVE_TASK' : 'ASK_WITHOUT_TOOLS'
|
||||
}, AbortSignal.timeout(30_000))) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
if (event.type === 'question') {
|
||||
expect(event.questions).toHaveLength(2)
|
||||
await runtime.respondToQuestion(event.questionId, [['Yes'], ['Custom answer']])
|
||||
pending.push({ id: event.questionId, answers: event.questions.length === 2
|
||||
? [['Yes'], ['Custom answer']] : [['Parallel answer']] })
|
||||
// Both native calls must reach the desktop before either is answered.
|
||||
if (pending.length === 2) {
|
||||
expect(pending[0]!.id).not.toBe(pending[1]!.id)
|
||||
for (const question of pending) await runtime.respondToQuestion(question.id, question.answers)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(text).toBe(mode === 'execute' ? 'PARENT_OK' : 'ASK_OK')
|
||||
expect(pending).toHaveLength(mode === 'execute' ? 2 : 0)
|
||||
}
|
||||
expect(readVerified).toBe(true)
|
||||
expect(answerVerified).toBe(true)
|
||||
|
||||
@@ -2281,6 +2281,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it("keeps concurrent child questions independently replyable and attributes only explicit task sessions", async () => {
|
||||
const question = (id: string, sessionID: string) => ({
|
||||
type: "question.asked", properties: { id, sessionID,
|
||||
questions: [{ header: "Scope", question: `Choose ${id}`, options: [] }] },
|
||||
});
|
||||
const setup = runClient([
|
||||
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
|
||||
id: "task-part", callID: "task-call", type: "tool", tool: "task",
|
||||
state: { status: "running", input: { subagent_type: "general", prompt: "Inspect", description: "Inspect task" },
|
||||
metadata: { sessionId: "child-session" }, time: { start: 1 } },
|
||||
} } },
|
||||
question("first", "child-session"),
|
||||
question("second", "other-child"),
|
||||
question("first", "child-session"),
|
||||
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
|
||||
id: "task-part", callID: "task-call", type: "tool", tool: "task",
|
||||
state: { status: "completed", input: { subagent_type: "general", prompt: "Inspect" },
|
||||
output: "Finished", time: { start: 1, end: 2 } },
|
||||
} } },
|
||||
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
]);
|
||||
vi.mocked(setup.client.session.get).mockResolvedValue({ data: { parentID: "session-1" } } as never);
|
||||
const runtime = embeddedRuntime(setup.client);
|
||||
const stream = runtime.run({ requestId: crypto.randomUUID(), conversationId: "parallel",
|
||||
prompt: "test", workMode: "execute" }, new AbortController().signal);
|
||||
await stream.next();
|
||||
const task = (await stream.next()).value;
|
||||
expect(task?.type).toBe("subagent");
|
||||
const first = (await stream.next()).value;
|
||||
const second = (await stream.next()).value;
|
||||
if (task?.type !== "subagent" || first?.type !== "question" || second?.type !== "question") throw new Error("Missing questions");
|
||||
expect(first.childTaskId).toBe(task.childTaskId);
|
||||
expect(second.childTaskId).toBeUndefined();
|
||||
expect(first.questionId).not.toBe(second.questionId);
|
||||
setup.questionReply.mockRejectedValueOnce(new Error("Reply failed"));
|
||||
await expect(runtime.respondToQuestion(first.questionId, [["Answer"]])).rejects.toThrow("Reply failed");
|
||||
await runtime.respondToQuestion(first.questionId, [["Answer"]]);
|
||||
await runtime.respondToQuestion(second.questionId);
|
||||
expect(setup.questionReply).toHaveBeenLastCalledWith(expect.objectContaining({ requestID: "first", answers: [["Answer"]] }), expect.anything());
|
||||
expect(setup.questionReject).toHaveBeenCalledWith(expect.objectContaining({ requestID: "second" }), expect.anything());
|
||||
const remaining = [];
|
||||
for await (const event of stream) remaining.push(event);
|
||||
expect(remaining.some((event) => event.type === "question")).toBe(false);
|
||||
await expect(runtime.respondToQuestion(first.questionId, [["Late"]])).rejects.toThrow();
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it("namespaces identical upstream question IDs across concurrent external conversations", async () => {
|
||||
const setup = runClient([]);
|
||||
(
|
||||
|
||||
@@ -2464,6 +2464,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
return false;
|
||||
};
|
||||
const reportedMessageIds = new Set<string>();
|
||||
const childTaskIdsBySession = new Map<string, string>();
|
||||
let waitingForRetry = false;
|
||||
for await (const event of subscription.stream) {
|
||||
const childProgress = subagentProgress.update(event);
|
||||
@@ -2621,6 +2622,10 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
...(subagent ? { subagent } : {}),
|
||||
});
|
||||
if (subagent) {
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined;
|
||||
if (isRecord(metadata) && typeof metadata.sessionId === "string") {
|
||||
childTaskIdsBySession.set(metadata.sessionId, subagent.childTaskId);
|
||||
}
|
||||
const retained = subagentProgress.retain(subagent);
|
||||
toolStates.get(callId)!.subagent = retained;
|
||||
yield retained;
|
||||
@@ -2693,6 +2698,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
requestId: request.requestId,
|
||||
type: "question",
|
||||
questionId: publicQuestionId,
|
||||
childTaskId: childTaskIdsBySession.get(event.properties.sessionID),
|
||||
questions: questionRequest.questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
remoteSemanticTranscriptPageResultSchema,
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import type { z } from 'zod'
|
||||
import type { RemoteQuestionResponse } from '../../shared/remote-question-contracts'
|
||||
import type {
|
||||
ModelBridgePolicy
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
@@ -91,6 +92,7 @@ export interface RemoteModelBridgeSession {
|
||||
* The runtime intentionally receives no SSH client, socket or command API.
|
||||
*/
|
||||
export interface RemoteRuntimeChannel {
|
||||
respondToQuestion?(request: RemoteQuestionResponse): Promise<void>
|
||||
readonly input: ReadableStream<Uint8Array>
|
||||
readonly output: WritableStream<Uint8Array>
|
||||
readonly generation: number
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -132,6 +133,148 @@ function claimManualScheduleQueueItem(
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it.each([0, 1, 100, 500, 999])('keeps listTasks(%i) recent history plus old visible live tasks', async (limit) => {
|
||||
const database = await createDatabase()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
try {
|
||||
const projectId = database.listProjects()[0]!.id
|
||||
const otherProject = database.createProject({
|
||||
name: 'Other project', description: '', rootPath: 'C:\\Other',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
const active = ['running', 'waiting_approval'].map((status, index) =>
|
||||
database.createTask({
|
||||
id: randomUUID(), projectId: index ? otherProject.id : projectId,
|
||||
title: status, instructions: '', workMode: 'execute',
|
||||
status: status as 'running' | 'waiting_approval'
|
||||
})
|
||||
)
|
||||
database.createTask({
|
||||
id: randomUUID(), title: 'Hidden live task', instructions: '',
|
||||
workMode: 'execute', visible: false
|
||||
})
|
||||
const history = Array.from({ length: 501 }, (_, index) => {
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 1, 1) + index * 1000))
|
||||
return database.createTask({
|
||||
id: randomUUID(), projectId, title: `History ${index}`,
|
||||
instructions: '', workMode: 'ask',
|
||||
status: index === 500 ? 'running' : 'completed'
|
||||
})
|
||||
}).reverse()
|
||||
const expected = [
|
||||
...history.slice(0, Math.max(1, Math.min(500, limit))),
|
||||
...active
|
||||
]
|
||||
const tasks = database.listTasks(limit)
|
||||
expect(tasks).toHaveLength(expected.length)
|
||||
expect(new Set(tasks.map((task) => task.id))).toEqual(
|
||||
new Set(expected.map((task) => task.id))
|
||||
)
|
||||
expect(tasks.slice(0, expected.length - active.length)).toEqual(
|
||||
history.slice(0, Math.max(1, Math.min(500, limit)))
|
||||
)
|
||||
expect(tasks.find((task) => task.id === active[1]!.id)?.projectId)
|
||||
.toBe(otherProject.id)
|
||||
if (limit === 100) {
|
||||
expect(database.listTasks()).toEqual(tasks)
|
||||
}
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('includes old live schedule and streaming conversations beyond recent history without exposing hidden or archived entries', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-live-history-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize('C:\\Workspace')
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
try {
|
||||
const projectId = database.listProjects()[0]!.id
|
||||
const schedules = ['running', 'approval', 'pending', 'idle', 'hidden', 'archived']
|
||||
.map((title) => database.createSchedule({
|
||||
projectId, title, prompt: title, recurrence: 'daily',
|
||||
nextRunAt: '2027-01-01T00:00:00Z'
|
||||
}))
|
||||
const [running, approval, pending, , hidden, archived] = schedules
|
||||
for (const schedule of [running!, approval!, hidden!, archived!]) {
|
||||
claimManualScheduleQueueItem(database, schedule.id)
|
||||
}
|
||||
database.updateTaskStatus(approval!.taskId, 'waiting_approval')
|
||||
database.queueScheduleNow(pending!.id)
|
||||
database.updateTaskStatus(pending!.taskId, 'running')
|
||||
raw.prepare('UPDATE tasks SET visible = 0 WHERE id = ?').run(hidden!.taskId)
|
||||
raw.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?")
|
||||
.run(archived!.conversationId)
|
||||
// Queued schedule tasks normally read as idle, unless a run overrides them.
|
||||
raw.prepare("UPDATE tasks SET status = 'queued' WHERE id = ?").run(running!.taskId)
|
||||
expect(database.listTasks().find((task) => task.id === pending!.taskId)?.status)
|
||||
.toBe('queued')
|
||||
const liveConversations = ['running', 'waiting_approval'].map((status) => {
|
||||
const id = randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id, projectId, title: status, updatedAt: 1 }, messages: []
|
||||
}])
|
||||
database.createTask({
|
||||
id: randomUUID(), projectId, conversationId: id,
|
||||
title: status, instructions: '', workMode: 'execute',
|
||||
status: status as 'running' | 'waiting_approval'
|
||||
})
|
||||
return id
|
||||
})
|
||||
const streamingId = randomUUID()
|
||||
const streamingMessage = {
|
||||
id: randomUUID(), role: 'assistant' as const, content: 'Still working',
|
||||
state: 'streaming' as const, createdAt: 1
|
||||
}
|
||||
database.saveLocalConversations([{
|
||||
header: { id: streamingId, projectId, title: 'Streaming', updatedAt: 1 },
|
||||
messages: [streamingMessage]
|
||||
}])
|
||||
const recent = Array.from({ length: 101 }, (_, index) => ({
|
||||
header: {
|
||||
id: randomUUID(), projectId, title: `Recent ${index}`,
|
||||
updatedAt: Date.UTC(2026, 1, 1) + index * 1000
|
||||
},
|
||||
messages: []
|
||||
}))
|
||||
database.saveLocalConversations(recent)
|
||||
vi.setSystemTime(new Date('2026-02-01T00:00:00Z'))
|
||||
const historyTask = database.createTask({
|
||||
id: randomUUID(), projectId, title: 'Recent completed task',
|
||||
instructions: '', workMode: 'ask', status: 'completed'
|
||||
})
|
||||
const tasks = database.listTasks(1)
|
||||
expect(tasks).toHaveLength(6)
|
||||
expect(tasks).toEqual(expect.arrayContaining([
|
||||
historyTask,
|
||||
expect.objectContaining({ id: running!.taskId, status: 'running' }),
|
||||
expect.objectContaining({ id: approval!.taskId, status: 'waiting_approval' }),
|
||||
expect.objectContaining({ id: archived!.taskId, status: 'running' })
|
||||
]))
|
||||
const conversations = database.listConversations()
|
||||
expect(conversations).toHaveLength(105)
|
||||
expect(new Set(conversations.map((conversation) => conversation.id))).toEqual(
|
||||
new Set([
|
||||
...recent.slice(1).map((conversation) => conversation.header.id),
|
||||
running!.conversationId, approval!.conversationId, streamingId,
|
||||
...liveConversations
|
||||
])
|
||||
)
|
||||
expect(conversations.find((conversation) => conversation.id === streamingId))
|
||||
.toMatchObject({ projectId, messages: [streamingMessage] })
|
||||
expect(conversations.slice(0, 100).map((conversation) => conversation.id))
|
||||
.toEqual(recent.slice(1).reverse().map((conversation) => conversation.header.id))
|
||||
} finally {
|
||||
raw.close()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps timed messages independent of creation-time execution settings', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.createProject({
|
||||
@@ -3460,7 +3603,13 @@ describe('AssistantDatabase', () => {
|
||||
snippet: '可以继续恢复。',
|
||||
rank: 1,
|
||||
retrievalChannels: ['fts']
|
||||
}
|
||||
},
|
||||
...['first', 'second', 'first'].map(remoteChunkId => ({
|
||||
libraryId: '00000000-0000-4000-8000-000000000708',
|
||||
libraryName: 'External', documentName: 'Remote', sourceName: 'Remote',
|
||||
snippet: 'same excerpt', rank: 2,
|
||||
external: { kind: 'external' as const, provider: 'dify' as const, instanceId: 'instance', remoteKnowledgeBaseId: 'dataset', remoteChunkId }
|
||||
}))
|
||||
]
|
||||
})
|
||||
append('8', 0, {
|
||||
@@ -3489,7 +3638,9 @@ describe('AssistantDatabase', () => {
|
||||
resultCount: 1
|
||||
},
|
||||
sourceReferences: [
|
||||
expect.objectContaining({ documentName: '恢复说明.md' })
|
||||
expect.objectContaining({ documentName: '恢复说明.md' }),
|
||||
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'first' }) }),
|
||||
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second' }) })
|
||||
],
|
||||
subagents: [
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
@@ -588,6 +589,14 @@ const taskSelect = `SELECT tasks.*,
|
||||
LIMIT 1) AS active_run_status
|
||||
FROM tasks`
|
||||
|
||||
// Match toTask's live statuses: pending schedule runs normalize to queued.
|
||||
const activeVisibleTaskSelect = `${taskSelect}
|
||||
WHERE visible = 1 AND (
|
||||
active_run_status = 'running'
|
||||
OR (active_run_status IS NULL
|
||||
AND status IN ('running', 'waiting_approval'))
|
||||
)`
|
||||
|
||||
function toTask(row: TaskRow): AssistantTask {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -1470,16 +1479,7 @@ function reduceRecoveredAgentEvent(
|
||||
: undefined
|
||||
}
|
||||
} else if (event.type === 'source-references') {
|
||||
const key = (
|
||||
reference: (typeof event.references)[number]
|
||||
): string =>
|
||||
[
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? '',
|
||||
reference.locator ?? '',
|
||||
reference.snippet
|
||||
].join('\0')
|
||||
const key = knowledgeReferenceKey
|
||||
const incoming = [
|
||||
...new Map(
|
||||
event.references.map((reference) => [key(reference), reference])
|
||||
@@ -2612,8 +2612,15 @@ export class AssistantDatabase {
|
||||
updated_at
|
||||
FROM conversations
|
||||
WHERE status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 100`
|
||||
AND (
|
||||
id IN (SELECT id FROM conversations WHERE status = 'active'
|
||||
ORDER BY updated_at DESC LIMIT 100)
|
||||
OR id IN (SELECT conversation_id FROM (${activeVisibleTaskSelect}))
|
||||
OR EXISTS (SELECT 1 FROM messages
|
||||
WHERE conversation_id = conversations.id
|
||||
AND state = 'streaming')
|
||||
)
|
||||
ORDER BY updated_at DESC`
|
||||
)
|
||||
.all() as ConversationRow[]
|
||||
const messageStatement = database.prepare(
|
||||
@@ -4195,8 +4202,12 @@ export class AssistantDatabase {
|
||||
.prepare(
|
||||
`${taskSelect}
|
||||
WHERE visible = 1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
AND (
|
||||
id IN (SELECT id FROM tasks WHERE visible = 1
|
||||
ORDER BY created_at DESC LIMIT ?)
|
||||
OR id IN (SELECT id FROM (${activeVisibleTaskSelect}))
|
||||
)
|
||||
ORDER BY created_at DESC`
|
||||
)
|
||||
.all(safeLimit) as TaskRow[]
|
||||
return rows.map(toTask)
|
||||
|
||||
@@ -854,6 +854,7 @@ if (hasSingleInstanceLock) {
|
||||
runtimeExtensionStore.markStartupFailed(extensionIds)
|
||||
})
|
||||
const startupKnowledgeService = new KnowledgeService({
|
||||
credentialCipher: secureCipher,
|
||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
||||
extractStructured: createModelGraphExtractor(settingsStore),
|
||||
|
||||
+65
-12
@@ -25,6 +25,7 @@ import type {
|
||||
} from '../shared/ssh-host-contracts'
|
||||
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { KnowledgeService } from './knowledge/knowledge-service'
|
||||
import { BrowserNavigationStoppedError } from './browser/browser-service'
|
||||
import {
|
||||
registerIpcHandlers,
|
||||
@@ -3087,6 +3088,7 @@ describe('registerIpcHandlers knowledge snapshot ontology', () => {
|
||||
it('exposes per-library ontology settings and rebuild state', async () => {
|
||||
const libraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const knowledgeService = {
|
||||
database: { externalStore: { listBindings: () => [] } },
|
||||
snapshot: vi.fn(() => ({
|
||||
libraries: [
|
||||
{
|
||||
@@ -8236,7 +8238,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('preflights always-retrieve mode and injects bounded untrusted evidence', async () => {
|
||||
it.each([false, true])('preflights always-retrieve mode with external=%s and injects bounded untrusted evidence', async (external) => {
|
||||
const libraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const documentId = '33333333-3333-4333-8333-333333333333'
|
||||
const chunkId = '44444444-4444-4444-8444-444444444444'
|
||||
@@ -8291,15 +8293,16 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
results: [
|
||||
{
|
||||
knowledgeBaseId: libraryId,
|
||||
documentId,
|
||||
sourceId: '55555555-5555-4555-8555-555555555555',
|
||||
chunkId,
|
||||
documentId: external ? undefined : documentId,
|
||||
sourceId: external ? undefined : '55555555-5555-4555-8555-555555555555',
|
||||
chunkId: external ? undefined : chunkId,
|
||||
external: external ? { kind: 'external' as const, provider: 'dify' as const, instanceId: libraryId, remoteKnowledgeBaseId: 'remote-kb', remoteChunkId: 'remote-chunk', location: 'Remote section 3' } : undefined,
|
||||
documentTitle: '离线部署.md',
|
||||
sourceDisplayName: '产品手册',
|
||||
sourceType: 'file' as const,
|
||||
location: '第 2 节',
|
||||
snippet: '离线部署需要先校验安装包',
|
||||
relevance: 0.9,
|
||||
relevance: external ? undefined : 0.9,
|
||||
rank: 1,
|
||||
channels: ['fts' as const],
|
||||
scores: {
|
||||
@@ -8323,6 +8326,10 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
]
|
||||
}
|
||||
}
|
||||
if (external) {
|
||||
retrievalResponse.context.groups = []
|
||||
retrievalResponse.results.push({ ...retrievalResponse.results[0]!, rank: 2, external: { ...retrievalResponse.results[0]!.external!, remoteChunkId: 'second-chunk' } })
|
||||
}
|
||||
const retrieveMany = vi.fn(async () => [
|
||||
{ knowledgeBaseId: libraryId, response: retrievalResponse }
|
||||
])
|
||||
@@ -8337,6 +8344,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: true,
|
||||
supportsScopedDataTools: !external,
|
||||
run
|
||||
},
|
||||
undefined,
|
||||
@@ -8387,20 +8395,19 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
expect.objectContaining({
|
||||
type: 'knowledge-retrieval',
|
||||
state: 'succeeded',
|
||||
resultCount: 1
|
||||
resultCount: external ? 2 : 1
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'source-references',
|
||||
references: [
|
||||
expect.objectContaining({
|
||||
chunkId,
|
||||
documentId
|
||||
})
|
||||
]
|
||||
references: external ? expect.arrayContaining([
|
||||
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'remote-chunk' }) }),
|
||||
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second-chunk' }) })
|
||||
]) : [expect.objectContaining({ chunkId, documentId })]
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
if (external) expect(run.mock.calls[0]![0].prompt).toContain('"locator":"Remote section 3"')
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
@@ -8480,6 +8487,52 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('routes external UI payloads through IPC, the production service and citation search', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-external-ipc-'))
|
||||
const fetcher = vi.fn<typeof fetch>(async (_url, init) => Response.json(init?.method === 'POST'
|
||||
? { records: [{ score: 0.75, segment: { id: 'remote-chunk', content: 'Policy evidence', document: { id: 'remote-document', name: 'Policy' } } }] }
|
||||
: { data: [{ id: 'dataset', name: 'Policy' }], has_more: false }))
|
||||
const service = new KnowledgeService({
|
||||
databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'),
|
||||
externalFetcher: fetcher,
|
||||
credentialCipher: { isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString() }
|
||||
})
|
||||
await service.initialize()
|
||||
const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, service as unknown as Record<string, unknown>)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const invoke = (channel: string, input?: unknown) => electronMocks.handlers.get(channel)!(event, input)
|
||||
try {
|
||||
const instance = await invoke(ipcChannels.externalInstancesSave, {
|
||||
name: 'External instance', provider: 'dify', baseUrl: 'https://kb.example', enabled: true,
|
||||
credential: { action: 'replace', value: 'test-key' }
|
||||
}) as { id: string }
|
||||
expect(instance).not.toHaveProperty('credential')
|
||||
await expect(invoke(ipcChannels.externalCatalogList, { instanceId: instance.id })).resolves.toMatchObject({ items: [{ id: 'dataset' }] })
|
||||
await expect(invoke(ipcChannels.externalBindingsCreate, { instanceId: instance.id })).rejects.toThrow()
|
||||
const snapshot = await invoke(ipcChannels.externalBindingsCreate, {
|
||||
instanceId: instance.id, remoteKnowledgeBaseId: 'dataset', name: 'Policy', remoteName: 'Policy',
|
||||
testQuery: 'policy', commonConfig: { resultLimit: 6, requestTimeoutMs: 1000, maxSnippetCharacters: 4000 },
|
||||
providerConfig: { provider: 'dify', useDatasetDefaults: true }
|
||||
}) as { libraries: Array<{ id: string; kind: string }> }
|
||||
expect(snapshot.libraries[0]?.kind).toBe('external')
|
||||
const libraryId = snapshot.libraries[0]!.id
|
||||
await expect(invoke(ipcChannels.knowledgeRetrieve, { knowledgeBaseId: libraryId, query: 'policy' })).resolves.toMatchObject({
|
||||
results: [{ external: { remoteChunkId: 'remote-chunk' }, snippet: 'Policy evidence' }]
|
||||
})
|
||||
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).resolves.toMatchObject([
|
||||
{ libraryId, documentId: undefined, snippet: 'Policy evidence', external: { remoteDocumentId: 'remote-document', remoteChunkId: 'remote-chunk', providerScore: 0.75 } }
|
||||
])
|
||||
await invoke(ipcChannels.externalInstancesSetEnabled, { instanceId: instance.id, enabled: false })
|
||||
fetcher.mockClear()
|
||||
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).rejects.toThrow('EXTERNAL_KB_DISABLED')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
await service.dispose()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('routes status and concurrent conversations to their selected runtimes', async () => {
|
||||
const firstProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
const secondProfileId = '00000000-0000-4000-8000-000000000002'
|
||||
|
||||
+48
-51
@@ -1,3 +1,5 @@
|
||||
import { externalKnowledgeInstanceInputSchema, externalKnowledgeInstanceEnabledInputSchema, externalKnowledgeInstanceSaveInputSchema, externalKnowledgeCatalogListInputSchema, externalKnowledgeCatalogGetInputSchema, externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeBindingTestInputSchema } from '../shared/external-knowledge-contracts'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -992,6 +994,8 @@ function getKnowledgeSnapshot(
|
||||
}
|
||||
return {
|
||||
libraries: snapshot.libraries.map((library) => ({
|
||||
kind: service.database.externalStore.listBindings().some(item=>item.knowledgeBaseId===library.id) ? 'external' : 'local',
|
||||
external: service.database.externalStore.listBindings().find(item=>item.knowledgeBaseId===library.id),
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
description: library.description ?? '',
|
||||
@@ -1092,6 +1096,7 @@ function buildForcedKnowledgeEvidence(
|
||||
promptContext?: string
|
||||
references: KnowledgeSearchReference[]
|
||||
} {
|
||||
const seen = new Set<string>()
|
||||
const ranked = entries
|
||||
.flatMap((entry) =>
|
||||
entry.response.results.map((result) => ({
|
||||
@@ -1104,35 +1109,26 @@ function buildForcedKnowledgeEvidence(
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.result.relevance - left.result.relevance ||
|
||||
right.result.scores.fusedScore -
|
||||
left.result.scores.fusedScore ||
|
||||
left.result.chunkId.localeCompare(right.result.chunkId)
|
||||
left.result.rank - right.result.rank ||
|
||||
left.entry.libraryId.localeCompare(right.entry.libraryId)
|
||||
)
|
||||
.filter(({ entry, result }) => {
|
||||
const key = knowledgeReferenceKey(toKnowledgeReference(result, entry.libraryName))
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.slice(0, 20)
|
||||
const references: KnowledgeSearchReference[] = ranked.map(
|
||||
({ entry, result }, index) => ({
|
||||
libraryId: entry.libraryId,
|
||||
libraryName: entry.libraryName,
|
||||
documentId: result.documentId,
|
||||
chunkId: result.chunkId,
|
||||
documentName: result.documentTitle,
|
||||
sourceName: result.sourceDisplayName,
|
||||
locator: result.location,
|
||||
snippet: stripKnowledgeHighlightTags(result.snippet),
|
||||
rank: index + 1,
|
||||
score: result.scores.fusedScore,
|
||||
lexicalRank: result.scores.ftsRank,
|
||||
vectorRank: result.scores.vectorRank,
|
||||
graphRank: result.scores.graphRank,
|
||||
similarity: result.scores.vectorSimilarity,
|
||||
retrievalChannels: result.channels
|
||||
...toKnowledgeReference(result, entry.libraryName),
|
||||
rank: index + 1
|
||||
})
|
||||
)
|
||||
if (ranked.length === 0) {
|
||||
return { references }
|
||||
}
|
||||
let remainingCharacters = 24_000
|
||||
let remainingCharacters = 48_000
|
||||
const evidence: Array<{
|
||||
citation: number
|
||||
library: string
|
||||
@@ -1161,7 +1157,7 @@ function buildForcedKnowledgeEvidence(
|
||||
library: item.entry.libraryName,
|
||||
document: item.result.documentTitle,
|
||||
source: item.result.sourceDisplayName,
|
||||
locator: item.result.location,
|
||||
locator: item.result.external?.location ?? item.result.location,
|
||||
text
|
||||
})
|
||||
remainingCharacters -= text.length
|
||||
@@ -4317,12 +4313,7 @@ export function registerIpcHandlers(
|
||||
request.knowledgeCapabilityToken
|
||||
) ?? [])
|
||||
].map((reference) => [
|
||||
[
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? '',
|
||||
reference.locator ?? ''
|
||||
].join('\0'),
|
||||
knowledgeReferenceKey(reference),
|
||||
reference
|
||||
])
|
||||
).values()
|
||||
@@ -4379,6 +4370,9 @@ export function registerIpcHandlers(
|
||||
libraryNames.get(knowledgeBaseId) ?? '知识库',
|
||||
response
|
||||
}))
|
||||
if (entries.length && entries.every(entry => entry.response.diagnostics.failure)) {
|
||||
throw new Error(entries.map(entry => `${entry.libraryName}: ${entry.response.diagnostics.failure}`).join('; '))
|
||||
}
|
||||
const evidence = buildForcedKnowledgeEvidence(entries)
|
||||
preflightReferences = evidence.references
|
||||
const usedChannels = [
|
||||
@@ -4390,13 +4384,13 @@ export function registerIpcHandlers(
|
||||
)
|
||||
]
|
||||
const warnings = entries.flatMap((entry) =>
|
||||
entry.response.diagnostics.degradedChannels.map(
|
||||
[...(entry.response.diagnostics.failure ? [`${entry.libraryName} · ${entry.response.diagnostics.failure}`] : []), ...entry.response.diagnostics.degradedChannels.map(
|
||||
(item) =>
|
||||
`${entry.libraryName} · ${item.reason}`.slice(
|
||||
0,
|
||||
500
|
||||
)
|
||||
)
|
||||
)]
|
||||
)
|
||||
if (evidence.promptContext) {
|
||||
executionRequest = {
|
||||
@@ -4641,6 +4635,10 @@ export function registerIpcHandlers(
|
||||
runtime: selectedRuntime
|
||||
})
|
||||
}
|
||||
if (publicEvent.type === 'question-resolved' &&
|
||||
pendingAgentQuestions.get(publicEvent.questionId)?.requestId === request.requestId) {
|
||||
pendingAgentQuestions.delete(publicEvent.questionId)
|
||||
}
|
||||
if (publicEvent.type === 'error') {
|
||||
runtimeErrorEvent = publicEvent
|
||||
if (provenance === undefined) {
|
||||
@@ -7901,6 +7899,16 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.externalInstancesList, (event) => {assertTrustedSender(event,window);return knowledgeService.external.listInstances()})
|
||||
registerHandler(ipcChannels.externalInstancesSave, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.saveInstance(externalKnowledgeInstanceSaveInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalInstancesTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
|
||||
registerHandler(ipcChannels.externalInstancesSetEnabled, (event,input:unknown) => {assertTrustedSender(event,window);const value=externalKnowledgeInstanceEnabledInputSchema.parse(input);return knowledgeService.external.setEnabled(value.instanceId,value.enabled)})
|
||||
registerHandler(ipcChannels.externalInstancesDelete, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.deleteInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
|
||||
registerHandler(ipcChannels.externalCatalogList, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.listCatalog(externalKnowledgeCatalogListInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalCatalogGet, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.getCatalog(externalKnowledgeCatalogGetInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalRetrievalTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testRetrieval(externalKnowledgeBindingTestInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalBindingsCreate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingSaveInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
|
||||
registerHandler(ipcChannels.externalBindingsUpdate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingUpdateInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
|
||||
registerHandler(ipcChannels.knowledgeSnapshot, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const libraryId =
|
||||
@@ -8064,30 +8072,19 @@ export function registerIpcHandlers(
|
||||
availableLibraries.map((library) => [library.id, library.name])
|
||||
)
|
||||
const results = (
|
||||
await knowledgeService.searchHybridMany(
|
||||
await knowledgeService.retrieveMany(
|
||||
libraries,
|
||||
value.query,
|
||||
6
|
||||
value.query
|
||||
)
|
||||
).map(({ knowledgeBaseId, result }) => ({
|
||||
libraryId: knowledgeBaseId,
|
||||
libraryName: names.get(knowledgeBaseId) ?? '知识库',
|
||||
documentId: result.document.id,
|
||||
chunkId: result.chunk.id,
|
||||
documentName: result.document.title,
|
||||
sourceName: result.source.displayName,
|
||||
locator: result.chunk.location,
|
||||
snippet: stripKnowledgeHighlightTags(result.snippet),
|
||||
rank: result.rank,
|
||||
score: result.retrieval.score,
|
||||
lexicalRank: result.retrieval.lexicalRank,
|
||||
vectorRank: result.retrieval.vectorRank,
|
||||
graphRank: result.retrieval.graphRank,
|
||||
similarity: result.retrieval.similarity,
|
||||
retrievalChannels: result.retrieval.channels,
|
||||
evidenceIds: result.retrieval.evidenceIds
|
||||
}))
|
||||
return results
|
||||
)
|
||||
const failures = results.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
|
||||
if (failures.length === results.length) throw new Error(failures.join('; '))
|
||||
const references = results.flatMap(({ knowledgeBaseId, response }) => response.results.map(result => ({
|
||||
...toKnowledgeReference(result, names.get(knowledgeBaseId) ?? '知识库'),
|
||||
...(failures.length ? { warnings: failures } : {})
|
||||
})))
|
||||
if (!references.length && failures.length) throw new Error(failures.join('; '))
|
||||
return [...new Map(references.map(reference => [knowledgeReferenceKey(reference), reference])).values()]
|
||||
.sort((left, right) => left.rank - right.rank)
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
+220
-3
@@ -1,7 +1,9 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ExternalKnowledgeClient,
|
||||
ExternalKnowledgeError
|
||||
ExternalKnowledgeError,
|
||||
type ExternalKnowledgeRetrievalConfig
|
||||
} from './external-knowledge-client'
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
@@ -52,7 +54,7 @@ describe('ExternalKnowledgeClient', () => {
|
||||
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ query: 'leave policy' })
|
||||
})
|
||||
|
||||
it('filters FastGPT folders and maps searchTest results', async () => {
|
||||
it('retains FastGPT folders for navigation and maps searchTest results', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
code: 200,
|
||||
@@ -83,7 +85,7 @@ describe('ExternalKnowledgeClient', () => {
|
||||
})
|
||||
|
||||
await expect(client.listKnowledgeBases()).resolves.toMatchObject({
|
||||
items: [{ id: 'dataset-1', name: 'Products' }]
|
||||
items: [{ id: 'folder-1', kind: 'folder' }, { id: 'dataset-1', name: 'Products', kind: 'dataset' }]
|
||||
})
|
||||
await expect(client.retrieve('dataset-1', 'pricing', {
|
||||
provider: 'fastgpt',
|
||||
@@ -98,6 +100,221 @@ describe('ExternalKnowledgeClient', () => {
|
||||
})])
|
||||
|
||||
expect(String(fetcher.mock.calls[1]?.[0])).toBe('https://fastgpt.example/api/core/dataset/searchTest')
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ parentId: null })
|
||||
})
|
||||
|
||||
it.each(['current', 'legacy'] as const)('preserves typed FastGPT scores and result order in the %s envelope', async (envelope) => {
|
||||
const scores = [{ type: 'fullText', value: 12.4, index: 2 }, { type: 'embedding', value: 0.8, index: 0 }, { type: 'reRank', value: 0.3, index: 1 }]
|
||||
const list = [{ q: 'First', score: scores, id: 'first' }, { q: 'Second', score: 0.99 }]
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 200, data: envelope === 'legacy' ? list : { list } }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const results = await client.retrieve('dataset', 'query', { provider: 'fastgpt', searchMode: 'mixedRecall', tokenLimit: 2000, similarity: 0.2, usingRerank: true })
|
||||
expect(results.map((item) => item.snippet)).toEqual(['First', 'Second'])
|
||||
expect(results[0]).toMatchObject({ providerScores: scores, remoteChunkId: 'first' })
|
||||
expect(results[0]?.providerScore).toBeUndefined()
|
||||
expect(results[0]?.remoteDocumentId).toBeUndefined()
|
||||
expect(results[1]?.providerScore).toBe(0.99)
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ datasetId: 'dataset', text: 'query', limit: 2000, similarity: 0.2, searchMode: 'mixedRecall', usingReRank: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ code: 200, data: { data: [] } },
|
||||
{ code: 500, data: [] },
|
||||
{ code: 200, data: [{ a: 'Answer without main text' }] },
|
||||
{ code: 200, data: [{ q: 'Text', score: [{ type: 'embedding', value: '0.8' }] }] }
|
||||
])('rejects unsupported FastGPT envelopes and malformed evidence', async (body) => {
|
||||
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(body)) })
|
||||
await expect(client.retrieve('dataset', 'query', { provider: 'fastgpt', searchMode: 'embedding', tokenLimit: 2000, similarity: 0, usingRerank: false })).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
|
||||
})
|
||||
|
||||
it('paginates FastGPT directory results locally and traverses parent IDs', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse({ code: 200, data: [
|
||||
{ _id: 'folder', name: 'Folder', type: 'folder' },
|
||||
{ _id: 'one', name: 'One', type: 'dataset' },
|
||||
{ _id: 'two', name: 'Two', type: 'dataset' }
|
||||
] }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
await expect(client.listKnowledgeBases(undefined, { parentId: 'parent', page: 2, pageSize: 2 })).resolves.toMatchObject({ items: [{ id: 'two' }], total: 3, hasMore: false })
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ parentId: 'parent' })
|
||||
await expect(client.listKnowledgeBases(undefined, { search: 'one', pageSize: 1 })).resolves.toMatchObject({ items: [{ id: 'one' }], total: 1, hasMore: false })
|
||||
})
|
||||
|
||||
it.each(['dify', 'ragflow'] as const)('requests subsequent %s pages and keeps continuation when local search has no matches', async (provider) => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(provider === 'dify'
|
||||
? { data: [{ id: 'one', name: 'One' }], total: 5, has_more: true }
|
||||
: { code: 0, data: [{ id: 'one', name: 'One' }], total_datasets: 5 }))
|
||||
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
await expect(client.listKnowledgeBases(undefined, { page: 2, pageSize: 1, search: 'missing' })).resolves.toMatchObject({ items: [], hasMore: true })
|
||||
const url = new URL(String(fetcher.mock.calls[0]?.[0]))
|
||||
expect(url.searchParams.get('page')).toBe('2')
|
||||
expect(url.searchParams.get(provider === 'dify' ? 'limit' : 'page_size')).toBe('1')
|
||||
})
|
||||
|
||||
it.each(['dify', 'fastgpt', 'ragflow'] as const)('reads %s details through its fixed read endpoint', async (provider) => {
|
||||
const record = { id: 'dataset', _id: 'dataset', name: 'Dataset', type: 'dataset', graphrag_task_finish_at: '2026-09-10T10:00:00Z', compilation_template_group_id: 'template' }
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(provider === 'dify' ? record : { code: provider === 'fastgpt' ? 200 : 0, data: provider === 'fastgpt' ? record : [record] }))
|
||||
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const detail = await client.getKnowledgeBase('dataset')
|
||||
expect(detail).toMatchObject({ id: 'dataset', name: 'Dataset', kind: 'dataset' })
|
||||
if (provider === 'ragflow') expect(detail).toMatchObject({ graphEnabled: true, knowledgeCompilationEnabled: true })
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe(`https://kb.example${provider === 'dify' ? '/v1/datasets/dataset' : provider === 'fastgpt' ? '/api/core/dataset/detail?id=dataset' : '/api/v1/datasets?id=dataset'}`)
|
||||
expect(fetcher.mock.calls[0]?.[1]?.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('distinguishes missing RAGFlow details and unsupported old Dify details', async () => {
|
||||
const ragflow = new ExternalKnowledgeClient({ provider: 'ragflow', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 0, data: [] })) })
|
||||
await expect(ragflow.getKnowledgeBase('missing')).rejects.toMatchObject({ code: 'EXTERNAL_KB_NOT_FOUND' })
|
||||
const dify = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 405 })) })
|
||||
await expect(dify.getKnowledgeBase('old')).rejects.toMatchObject({ code: 'EXTERNAL_KB_INCOMPATIBLE' })
|
||||
})
|
||||
|
||||
it('sends complete explicit Dify configuration and rejects missing reranking identifiers before fetch', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const retrievalModel = { search_method: 'hybrid_search', reranking_enable: true, top_k: 4, score_threshold_enabled: true, score_threshold: 0.2, reranking_mode: 'reranking_model', reranking_model: { reranking_provider_name: 'provider', reranking_model_name: 'model' } } as const
|
||||
await client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel })
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query: 'query', retrieval_model: retrievalModel })
|
||||
await expect(client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel: { ...retrievalModel, reranking_model: undefined } } as ExternalKnowledgeRetrievalConfig)).rejects.toThrow()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not fetch when already cancelled', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('private reason'))
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const operation = client.listKnowledgeBases(controller.signal)
|
||||
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
|
||||
await expect(operation).rejects.not.toHaveProperty('cause')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['headers', 'body'] as const)('distinguishes cancellation and timeout while reading %s', async (phase) => {
|
||||
for (const cancellation of [true, false]) {
|
||||
const controller = new AbortController()
|
||||
const cancelBody = vi.fn()
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => { started = resolve })
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
||||
if (phase === 'headers') return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
|
||||
started()
|
||||
})
|
||||
return new Response(new ReadableStream({ pull() { started() }, cancel: cancelBody }))
|
||||
})
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher, timeoutMs: cancellation ? 1000 : 20 })
|
||||
const operation = client.listKnowledgeBases(controller.signal)
|
||||
const assertion = expect(operation).rejects.toMatchObject({ code: cancellation ? 'EXTERNAL_KB_CANCELLED' : 'EXTERNAL_KB_TIMEOUT' })
|
||||
await ready
|
||||
if (cancellation) controller.abort()
|
||||
await assertion
|
||||
if (phase === 'body') expect(cancelBody).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps body transport failures to a sanitized network error', async () => {
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(new Response(new ReadableStream({ pull(controller) { controller.error(new Error('private transport detail')) } }))) })
|
||||
const operation = client.listKnowledgeBases()
|
||||
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_NETWORK' })
|
||||
await expect(operation).rejects.not.toHaveProperty('cause')
|
||||
})
|
||||
|
||||
it.each([200, 401])('cancels a response body when cancellation races with HTTP %s headers', async (status) => {
|
||||
const controller = new AbortController()
|
||||
const cancel = vi.fn()
|
||||
const response = new Response(new ReadableStream({ cancel }), { status })
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => {
|
||||
controller.abort()
|
||||
return response
|
||||
})
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
await expect(client.listKnowledgeBases(controller.signal)).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
|
||||
expect(cancel).toHaveBeenCalledTimes(1)
|
||||
expect(response.body?.locked).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['headers', 'body'] as const)('aborts real HTTP requests stalled at %s on cancellation and timeout', async (phase) => {
|
||||
for (const cancellation of [true, false]) {
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => { started = resolve })
|
||||
const server = createServer((_request, response) => {
|
||||
if (phase === 'body') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
response.write('{"data":[')
|
||||
}
|
||||
started()
|
||||
})
|
||||
try {
|
||||
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Missing test server address')
|
||||
const controller = new AbortController()
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: `http://127.0.0.1:${address.port}`, apiKey: 'test-key', timeoutMs: cancellation ? 2000 : 200 })
|
||||
const assertion = expect(client.listKnowledgeBases(controller.signal)).rejects.toMatchObject({ code: cancellation ? 'EXTERNAL_KB_CANCELLED' : 'EXTERNAL_KB_TIMEOUT' })
|
||||
await ready
|
||||
if (cancellation) controller.abort()
|
||||
await assertion
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()) })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['dify', 'fastgpt', 'ragflow'] as const)('validates %s scalar scores without clamping or treating zero as missing', async (provider) => {
|
||||
for (const score of [0, 12.4, -0.5, undefined, null, '0.8', {}, true]) {
|
||||
const body = provider === 'dify' ? { records: [{ segment: { content: 'Text' }, score }] }
|
||||
: provider === 'fastgpt' ? { code: 200, data: { list: [{ q: 'Text', score }] } }
|
||||
: { code: 0, data: { chunks: [{ content: 'Text', similarity: score }] } }
|
||||
const config: ExternalKnowledgeRetrievalConfig = provider === 'dify' ? { provider, useDatasetDefaults: true }
|
||||
: provider === 'fastgpt' ? { provider, searchMode: 'embedding', tokenLimit: 2000, similarity: 0, usingRerank: false }
|
||||
: { provider, similarityThreshold: 0, vectorSimilarityWeight: 0.3, knnTopK: 128, useKg: false, includeKnowledgeCompilation: false }
|
||||
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(body)) })
|
||||
const operation = client.retrieve('dataset', 'query', config)
|
||||
if (score === undefined || score === null || typeof score === 'number') {
|
||||
await expect(operation).resolves.toEqual([expect.objectContaining({ providerScore: score ?? undefined })])
|
||||
} else {
|
||||
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('counts Unicode code points for the Dify API query limit', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const query = '\u{20000}'.repeat(250)
|
||||
await expect(client.retrieve('dataset', query, { provider: 'dify', useDatasetDefaults: true })).resolves.toEqual([])
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query })
|
||||
await expect(client.retrieve('dataset', `${query}a`, { provider: 'dify', useDatasetDefaults: true })).rejects.toThrow('250 characters')
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not infer RAGFlow capabilities from configuration alone or invalid completion times', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 0, data: ['0', '0.0', '', 'invalid'].map((time, index) => ({ id: String(index), name: 'Dataset', graphrag_task_finish_at: time, parser_config: { graphrag: { use_graphrag: true } } })) }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'ragflow', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const page = await client.listKnowledgeBases()
|
||||
expect(page.items).toHaveLength(4)
|
||||
for (const item of page.items) expect(item).toMatchObject({ graphEnabled: false, knowledgeCompilationEnabled: false })
|
||||
})
|
||||
|
||||
it('sends Dify weighted configuration without merging and rejects inconsistent weights', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
|
||||
const retrievalModel = { search_method: 'hybrid_search', reranking_enable: true, top_k: 4, score_threshold_enabled: false, reranking_mode: 'weighted_score', weights: { weight_type: 'customized', vector_setting: { vector_weight: 0.7, embedding_provider_name: 'provider', embedding_model_name: 'model' }, keyword_setting: { keyword_weight: 0.3 } } } as const
|
||||
await client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel })
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query: 'query', retrieval_model: retrievalModel })
|
||||
await expect(client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel: { ...retrievalModel, weights: { ...retrievalModel.weights, keyword_setting: { keyword_weight: 0.7 } } } })).rejects.toThrow('weights must sum to one')
|
||||
await expect(client.retrieve('dataset', 'q'.repeat(251), { provider: 'dify', useDatasetDefaults: true })).rejects.toThrow('250 characters')
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([true, false])('cancels oversized response streams (declared length: %s)', async (declared) => {
|
||||
const cancel = vi.fn()
|
||||
const response = new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('123456')) }, cancel }), { headers: declared ? { 'content-length': '6' } : {} })
|
||||
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', maximumResponseBytes: 5, fetcher: vi.fn<typeof fetch>().mockResolvedValue(response) })
|
||||
await expect(client.listKnowledgeBases()).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
|
||||
expect(cancel).toHaveBeenCalledTimes(1)
|
||||
expect(response.body?.locked).toBe(false)
|
||||
})
|
||||
|
||||
it('maps RAGFlow graph capability and retrieval fields', async () => {
|
||||
|
||||
+144
-92
@@ -1,44 +1,21 @@
|
||||
export type ExternalKnowledgeProvider = 'dify' | 'fastgpt' | 'ragflow'
|
||||
import {
|
||||
externalKnowledgeProviderConfigSchema,
|
||||
type ExternalKnowledgeProvider,
|
||||
type ExternalKnowledgeCatalogItem,
|
||||
type ExternalKnowledgeCatalogPage,
|
||||
type ExternalKnowledgeProviderConfig,
|
||||
type ExternalKnowledgeRemoteResult
|
||||
} from '../../../shared/external-knowledge-contracts'
|
||||
|
||||
export type ExternalKnowledgeCatalogItem = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
graphEnabled?: boolean
|
||||
}
|
||||
export type { ExternalKnowledgeProvider, ExternalKnowledgeCatalogItem, ExternalKnowledgeCatalogPage }
|
||||
export type ExternalKnowledgeRetrievalConfig = ExternalKnowledgeProviderConfig
|
||||
export type ExternalKnowledgeRetrievalResult = ExternalKnowledgeRemoteResult
|
||||
|
||||
export type ExternalKnowledgeCatalogPage = {
|
||||
items: ExternalKnowledgeCatalogItem[]
|
||||
total?: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export type ExternalKnowledgeRetrievalConfig =
|
||||
| { provider: 'dify'; useDatasetDefaults: true }
|
||||
| {
|
||||
provider: 'fastgpt'
|
||||
searchMode: 'embedding' | 'fullTextRecall' | 'mixedRecall'
|
||||
tokenLimit: number
|
||||
similarity: number
|
||||
usingRerank: boolean
|
||||
}
|
||||
| {
|
||||
provider: 'ragflow'
|
||||
similarityThreshold: number
|
||||
vectorSimilarityWeight: number
|
||||
knnTopK: number
|
||||
useKg: boolean
|
||||
includeKnowledgeCompilation: boolean
|
||||
}
|
||||
|
||||
export type ExternalKnowledgeRetrievalResult = {
|
||||
documentTitle: string
|
||||
sourceDisplayName: string
|
||||
snippet: string
|
||||
providerScore?: number
|
||||
remoteDocumentId?: string
|
||||
remoteChunkId?: string
|
||||
location?: string
|
||||
export type ExternalKnowledgeCatalogInput = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
parentId?: string | null
|
||||
search?: string
|
||||
}
|
||||
|
||||
export class ExternalKnowledgeError extends Error {
|
||||
@@ -46,6 +23,8 @@ export class ExternalKnowledgeError extends Error {
|
||||
readonly code:
|
||||
| 'EXTERNAL_KB_NETWORK'
|
||||
| 'EXTERNAL_KB_TIMEOUT'
|
||||
| 'EXTERNAL_KB_CANCELLED'
|
||||
| 'EXTERNAL_KB_INCOMPATIBLE'
|
||||
| 'EXTERNAL_KB_AUTH'
|
||||
| 'EXTERNAL_KB_FORBIDDEN'
|
||||
| 'EXTERNAL_KB_NOT_FOUND'
|
||||
@@ -60,7 +39,7 @@ export class ExternalKnowledgeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type ExternalKnowledgeClientOptions = {
|
||||
export type ExternalKnowledgeClientOptions = {
|
||||
provider: ExternalKnowledgeProvider
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
@@ -109,6 +88,7 @@ function endpoint(baseUrl: URL, path: string): URL {
|
||||
}
|
||||
|
||||
function errorForStatus(status: number): ExternalKnowledgeError {
|
||||
if (status === 405) return new ExternalKnowledgeError('EXTERNAL_KB_INCOMPATIBLE', 'External knowledge operation is not supported by this deployment')
|
||||
if (status === 401) return new ExternalKnowledgeError('EXTERNAL_KB_AUTH', 'External knowledge authentication failed')
|
||||
if (status === 403) return new ExternalKnowledgeError('EXTERNAL_KB_FORBIDDEN', 'External knowledge access is forbidden')
|
||||
if (status === 404) return new ExternalKnowledgeError('EXTERNAL_KB_NOT_FOUND', 'External knowledge target was not found')
|
||||
@@ -116,36 +96,42 @@ function errorForStatus(status: number): ExternalKnowledgeError {
|
||||
return new ExternalKnowledgeError('EXTERNAL_KB_SERVER', `External knowledge service returned HTTP ${status}`)
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response, maximumBytes: number): Promise<unknown> {
|
||||
async function readBoundedJson(response: Response, maximumBytes: number, signal: AbortSignal): Promise<unknown> {
|
||||
const declaredLength = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
||||
void response.body?.cancel().catch(() => {})
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large')
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is empty')
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const cancel = (): void => { void reader.cancel().catch(() => {}) }
|
||||
signal.addEventListener('abort', cancel, { once: true })
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
while (true) {
|
||||
const part = await reader.read()
|
||||
signal.throwIfAborted()
|
||||
if (part.done) break
|
||||
total += part.value.byteLength
|
||||
if (total > maximumBytes) {
|
||||
await reader.cancel()
|
||||
cancel()
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large')
|
||||
}
|
||||
chunks.push(part.value)
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', cancel)
|
||||
reader.releaseLock()
|
||||
}
|
||||
const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')
|
||||
try {
|
||||
return JSON.parse(body) as unknown
|
||||
} catch (error) {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is not valid JSON', { cause: error })
|
||||
} catch {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is not valid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,16 +155,23 @@ export class ExternalKnowledgeClient {
|
||||
this.maximumResponseBytes = options.maximumResponseBytes ?? 5_000_000
|
||||
}
|
||||
|
||||
async listKnowledgeBases(signal?: AbortSignal): Promise<ExternalKnowledgeCatalogPage> {
|
||||
async listKnowledgeBases(signal?: AbortSignal, input: ExternalKnowledgeCatalogInput = {}): Promise<ExternalKnowledgeCatalogPage> {
|
||||
const { page = 1, pageSize = 100, parentId = null } = input
|
||||
if (!Number.isInteger(page) || page < 1 || page > 500 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
|
||||
throw new RangeError('Invalid external knowledge catalog pagination')
|
||||
}
|
||||
const search = input.search?.trim().toLowerCase()
|
||||
const filter = (items: ExternalKnowledgeCatalogItem[]): ExternalKnowledgeCatalogItem[] =>
|
||||
search ? items.filter((item) => `${item.name}\n${item.description ?? ''}`.toLowerCase().includes(search)) : items
|
||||
if (this.provider === 'dify') {
|
||||
const url = endpoint(this.normalizedBaseUrl, '/v1/datasets')
|
||||
url.searchParams.set('page', '1')
|
||||
url.searchParams.set('limit', '100')
|
||||
url.searchParams.set('page', String(page))
|
||||
url.searchParams.set('limit', String(pageSize))
|
||||
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
|
||||
const items = Array.isArray(body?.data) ? body.data : undefined
|
||||
if (!items) throw this.invalidResponse()
|
||||
return {
|
||||
items: items.slice(0, 100).map((item) => this.catalogItem(item)),
|
||||
items: filter(items.slice(0, pageSize).map((item) => this.catalogItem(item))),
|
||||
total: finiteNumber(body?.total),
|
||||
hasMore: body?.has_more === true
|
||||
}
|
||||
@@ -187,63 +180,81 @@ export class ExternalKnowledgeClient {
|
||||
if (this.provider === 'fastgpt') {
|
||||
const body = asRecord(await this.request(
|
||||
endpoint(this.normalizedBaseUrl, '/core/dataset/list'),
|
||||
{ method: 'POST', body: JSON.stringify({ pageNum: 1, pageSize: 100 }) },
|
||||
{ method: 'POST', body: JSON.stringify({ parentId }) },
|
||||
signal
|
||||
))
|
||||
const data = asRecord(body?.data)
|
||||
const items = Array.isArray(body?.data)
|
||||
? body.data
|
||||
: Array.isArray(data?.data)
|
||||
? data.data
|
||||
: Array.isArray(data?.list)
|
||||
? data.list
|
||||
: undefined
|
||||
const items = Array.isArray(body?.data) ? body.data : undefined
|
||||
if (body?.code !== 200 || !items) throw this.invalidResponse()
|
||||
const datasets = items.filter((item) => asRecord(item)?.type === 'dataset')
|
||||
const datasets = filter(items.filter((item) => ['dataset', 'folder'].includes(String(asRecord(item)?.type)))
|
||||
.map((item) => this.catalogItem(item, '_id', 'intro')))
|
||||
const offset = (page - 1) * pageSize
|
||||
return {
|
||||
items: datasets.slice(0, 100).map((item) => this.catalogItem(item, '_id', 'intro')),
|
||||
total: finiteNumber(data?.total) ?? datasets.length,
|
||||
hasMore: datasets.length > 100
|
||||
items: datasets.slice(offset, offset + pageSize),
|
||||
total: datasets.length,
|
||||
hasMore: datasets.length > offset + pageSize
|
||||
}
|
||||
}
|
||||
|
||||
const url = endpoint(this.normalizedBaseUrl, '/api/v1/datasets')
|
||||
url.searchParams.set('page', '1')
|
||||
url.searchParams.set('page_size', '100')
|
||||
url.searchParams.set('page', String(page))
|
||||
url.searchParams.set('page_size', String(pageSize))
|
||||
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
|
||||
if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse()
|
||||
return {
|
||||
items: body.data.slice(0, 100).map((item) => {
|
||||
const record = asRecord(item)
|
||||
const graphFinishedAt = record?.graphrag_task_finish_at
|
||||
return {
|
||||
...this.catalogItem(item),
|
||||
graphEnabled:
|
||||
(typeof graphFinishedAt === 'number' && graphFinishedAt > 0) ||
|
||||
(typeof graphFinishedAt === 'string' && graphFinishedAt.length > 0)
|
||||
}
|
||||
}),
|
||||
items: filter(body.data.slice(0, pageSize).map((item) => this.catalogItem(item))),
|
||||
total: finiteNumber(body.total_datasets),
|
||||
hasMore: (finiteNumber(body.total_datasets) ?? 0) > 100
|
||||
hasMore: finiteNumber(body.total_datasets) === undefined
|
||||
? body.data.length >= pageSize
|
||||
: Number(body.total_datasets) > page * pageSize
|
||||
}
|
||||
}
|
||||
|
||||
async getKnowledgeBase(remoteKnowledgeBaseId: string, signal?: AbortSignal): Promise<ExternalKnowledgeCatalogItem> {
|
||||
const id = remoteKnowledgeBaseId.trim()
|
||||
if (!id) throw new TypeError('Remote knowledge base ID is required')
|
||||
const url = endpoint(this.normalizedBaseUrl, this.provider === 'dify'
|
||||
? `/v1/datasets/${encodeURIComponent(id)}`
|
||||
: this.provider === 'fastgpt' ? '/core/dataset/detail' : '/api/v1/datasets')
|
||||
if (this.provider !== 'dify') url.searchParams.set('id', id)
|
||||
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
|
||||
let item: ExternalKnowledgeCatalogItem
|
||||
if (this.provider === 'dify') item = this.catalogItem(body)
|
||||
else if (this.provider === 'fastgpt') {
|
||||
if (body?.code !== 200) throw this.invalidResponse()
|
||||
item = this.catalogItem(body.data, '_id', 'intro')
|
||||
} else {
|
||||
if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse()
|
||||
const match = body.data.find((value) => asRecord(value)?.id === id)
|
||||
if (!match) throw errorForStatus(404)
|
||||
item = this.catalogItem(match)
|
||||
}
|
||||
if (item.id !== id) throw this.invalidResponse()
|
||||
return item
|
||||
}
|
||||
|
||||
async retrieve(
|
||||
remoteKnowledgeBaseId: string,
|
||||
query: string,
|
||||
config: ExternalKnowledgeRetrievalConfig,
|
||||
signal?: AbortSignal
|
||||
): Promise<ExternalKnowledgeRetrievalResult[]> {
|
||||
config = externalKnowledgeProviderConfigSchema.parse(config)
|
||||
if (config.provider !== this.provider) throw new TypeError('Provider configuration does not match client')
|
||||
const remoteId = remoteKnowledgeBaseId.trim()
|
||||
const question = query.trim()
|
||||
if (!remoteId || !question) throw new TypeError('Remote knowledge base ID and query are required')
|
||||
|
||||
if (this.provider === 'dify') {
|
||||
if (question.length > 250) throw new RangeError('Dify queries cannot exceed 250 characters')
|
||||
if (this.provider === 'dify' && config.provider === 'dify') {
|
||||
if (!config.useDatasetDefaults && config.retrievalModel.reranking_mode === 'weighted_score' && config.retrievalModel.weights) {
|
||||
const weights = config.retrievalModel.weights
|
||||
if (Math.abs(weights.vector_setting.vector_weight + weights.keyword_setting.keyword_weight - 1) > 1e-9) {
|
||||
throw new TypeError('Dify retrieval weights must sum to one')
|
||||
}
|
||||
}
|
||||
if (Array.from(question).length > 250) throw new RangeError('Dify queries cannot exceed 250 characters')
|
||||
const body = asRecord(await this.request(
|
||||
endpoint(this.normalizedBaseUrl, `/v1/datasets/${encodeURIComponent(remoteId)}/retrieve`),
|
||||
{ method: 'POST', body: JSON.stringify({ query: question }) },
|
||||
{ method: 'POST', body: JSON.stringify({ query: question, ...(!config.useDatasetDefaults ? { retrieval_model: config.retrievalModel } : {}) }) },
|
||||
signal
|
||||
))
|
||||
if (!Array.isArray(body?.records)) throw this.invalidResponse()
|
||||
@@ -267,8 +278,10 @@ export class ExternalKnowledgeClient {
|
||||
signal
|
||||
))
|
||||
const data = asRecord(body?.data)
|
||||
if (body?.code !== 200 || !Array.isArray(data?.list)) throw this.invalidResponse()
|
||||
return data.list.slice(0, 20).map((item) => this.fastGptResult(item))
|
||||
// The official legacy manual documents data[]; current OpenAPI uses data.list[].
|
||||
const items = Array.isArray(body?.data) ? body.data : data?.list
|
||||
if (body?.code !== 200 || !Array.isArray(items)) throw this.invalidResponse()
|
||||
return items.slice(0, 20).map((item) => this.fastGptResult(item))
|
||||
}
|
||||
|
||||
if (this.provider === 'ragflow' && config.provider === 'ragflow') {
|
||||
@@ -299,12 +312,13 @@ export class ExternalKnowledgeClient {
|
||||
}
|
||||
|
||||
private async request(url: URL, init: RequestInit, signal?: AbortSignal): Promise<unknown> {
|
||||
const timeoutSignal = AbortSignal.timeout(this.timeoutMs)
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)])
|
||||
: AbortSignal.timeout(this.timeoutMs)
|
||||
let response: Response
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal
|
||||
try {
|
||||
response = await this.fetcher(url, {
|
||||
effectiveSignal.throwIfAborted()
|
||||
const response = await this.fetcher(url, {
|
||||
...init,
|
||||
redirect: 'error',
|
||||
signal: effectiveSignal,
|
||||
@@ -314,20 +328,40 @@ export class ExternalKnowledgeClient {
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {})
|
||||
}
|
||||
})
|
||||
if (effectiveSignal.aborted || !response.ok) {
|
||||
void response.body?.cancel().catch(() => {})
|
||||
effectiveSignal.throwIfAborted()
|
||||
throw errorForStatus(response.status)
|
||||
}
|
||||
return await readBoundedJson(response, this.maximumResponseBytes, effectiveSignal)
|
||||
} catch (error) {
|
||||
const code = effectiveSignal.aborted ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_NETWORK'
|
||||
throw new ExternalKnowledgeError(code, code === 'EXTERNAL_KB_TIMEOUT' ? 'External knowledge request timed out' : 'External knowledge service is unreachable', { cause: error })
|
||||
if (effectiveSignal.aborted) {
|
||||
const timedOut = effectiveSignal.reason === timeoutSignal.reason && timeoutSignal.aborted
|
||||
throw new ExternalKnowledgeError(timedOut ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_CANCELLED',
|
||||
timedOut ? 'External knowledge request timed out' : 'External knowledge request was cancelled')
|
||||
}
|
||||
if (error instanceof ExternalKnowledgeError) throw error
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_NETWORK', 'External knowledge service is unreachable')
|
||||
}
|
||||
if (!response.ok) throw errorForStatus(response.status)
|
||||
return readBoundedJson(response, this.maximumResponseBytes)
|
||||
}
|
||||
|
||||
private catalogItem(value: unknown, idKey = 'id', descriptionKey = 'description'): ExternalKnowledgeCatalogItem {
|
||||
const record = asRecord(value)
|
||||
const id = boundedString(record?.[idKey], 128)
|
||||
const id = boundedString(record?.[idKey], 512)
|
||||
const name = boundedString(record?.name, 512)
|
||||
if (!id || !name) throw this.invalidResponse()
|
||||
return { id, name, description: boundedString(record?.[descriptionKey], 2_000) }
|
||||
const item: ExternalKnowledgeCatalogItem = { id, name, description: boundedString(record?.[descriptionKey], 2_000), kind: 'dataset' }
|
||||
if (this.provider === 'fastgpt') {
|
||||
if (record?.type !== 'dataset' && record?.type !== 'folder') throw this.invalidResponse()
|
||||
item.kind = record.type
|
||||
}
|
||||
if (this.provider === 'ragflow') {
|
||||
const finished = record?.graphrag_task_finish_at
|
||||
item.graphEnabled = typeof finished === 'number' ? Number.isFinite(finished) && finished > 0
|
||||
: typeof finished === 'string' && (Number.isFinite(Number(finished)) ? Number(finished) > 0 : Date.parse(finished) > 0)
|
||||
item.knowledgeCompilationEnabled = !!boundedString(record?.compilation_template_group_id, 512)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
private difyResult(value: unknown): ExternalKnowledgeRetrievalResult {
|
||||
@@ -341,7 +375,7 @@ export class ExternalKnowledgeClient {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet,
|
||||
providerScore: finiteNumber(record?.score),
|
||||
providerScore: this.resultScore(record?.score),
|
||||
remoteDocumentId: boundedString(document?.id, 128),
|
||||
remoteChunkId: boundedString(segment?.id, 128),
|
||||
location: finiteNumber(segment?.position) === undefined ? undefined : `segment ${segment?.position}`
|
||||
@@ -351,15 +385,26 @@ export class ExternalKnowledgeClient {
|
||||
private fastGptResult(value: unknown): ExternalKnowledgeRetrievalResult {
|
||||
const record = asRecord(value)
|
||||
const question = boundedString(record?.q, 8_000)
|
||||
if (!question) throw this.invalidResponse()
|
||||
const answer = boundedString(record?.a, 8_000)
|
||||
const snippet = [question, answer].filter(Boolean).join('\n')
|
||||
if (!snippet) throw this.invalidResponse()
|
||||
const title = boundedString(record?.sourceName, 512) ?? 'FastGPT document'
|
||||
// Preserve channel semantics and provider order; these are not one comparable total score.
|
||||
const providerScores = Array.isArray(record?.score) ? record.score.slice(0, 20).map((value) => {
|
||||
const score = asRecord(value)
|
||||
const type = boundedString(score?.type, 128)
|
||||
const number = finiteNumber(score?.value)
|
||||
const index = finiteNumber(score?.index)
|
||||
if (!type || number === undefined || (score?.index !== undefined && (index === undefined || !Number.isInteger(index)))) throw this.invalidResponse()
|
||||
return { type, value: number, ...(index === undefined ? {} : { index }) }
|
||||
}) : undefined
|
||||
return {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet: snippet.slice(0, 8_000),
|
||||
providerScore: finiteNumber(record?.score),
|
||||
providerScore: providerScores ? undefined : this.resultScore(record?.score),
|
||||
...(providerScores ? { providerScores } : {}),
|
||||
remoteDocumentId: boundedString(record?.sourceId, 128),
|
||||
remoteChunkId: boundedString(record?.id, 128),
|
||||
location: finiteNumber(record?.chunkIndex) === undefined ? undefined : `chunk ${record?.chunkIndex}`
|
||||
@@ -376,13 +421,20 @@ export class ExternalKnowledgeClient {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet,
|
||||
providerScore: finiteNumber(record?.similarity),
|
||||
providerScore: this.resultScore(record?.similarity),
|
||||
remoteDocumentId: boundedString(record?.document_id, 128),
|
||||
remoteChunkId: boundedString(record?.id, 128),
|
||||
location: positions ? JSON.stringify(positions).slice(0, 8_192) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private resultScore(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
const score = finiteNumber(value)
|
||||
if (score === undefined) throw this.invalidResponse()
|
||||
return score
|
||||
}
|
||||
|
||||
private invalidResponse(): ExternalKnowledgeError {
|
||||
return new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response does not match the supported API')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { KnowledgeService } from '../knowledge-service'
|
||||
import { KnowledgeMcpGateway } from '../../agent/knowledge-mcp-gateway'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../../../shared/knowledge-reference'
|
||||
import { conversationMessageSchema } from '../../../shared/assistant-contracts'
|
||||
|
||||
const services: KnowledgeService[] = []
|
||||
const directories: string[] = []
|
||||
const servers: Server[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(services.splice(0).map(service => service.dispose()))
|
||||
await Promise.all(servers.splice(0).map(server => new Promise<void>(resolve => server.close(() => resolve()))))
|
||||
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function setup(fetcher?: typeof fetch) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'external-kb-'))
|
||||
directories.push(directory)
|
||||
const path = join(directory, 'knowledge.db')
|
||||
const cipher = { isAvailable: () => true, encrypt: (value: string) => Buffer.from(value), decrypt: (value: Buffer) => value.toString() }
|
||||
const options = { databasePath: path, managedRoot: join(directory, 'managed'), externalFetcher: fetcher, credentialCipher: cipher }
|
||||
const service = new KnowledgeService(options)
|
||||
await service.initialize()
|
||||
services.push(service)
|
||||
return { service, path, cipher, options }
|
||||
}
|
||||
|
||||
const commonConfig = { resultLimit: 20, requestTimeoutMs: 1000, maxSnippetCharacters: 8000 }
|
||||
const providerConfig = { provider: 'dify' as const, useDatasetDefaults: true as const }
|
||||
const records = (count = 1, size = 16) => ({ records: Array.from({ length: count }, (_, index) => ({
|
||||
score: 0.8, segment: { id: `chunk-${index}`, content: 'e'.repeat(size), document: { id: 'remote-doc', name: 'Handbook' } }
|
||||
})) })
|
||||
|
||||
function instance(service: KnowledgeService, baseUrl = 'https://kb.example/v1') {
|
||||
return service.external.saveInstance({ name: 'Dify', provider: 'dify', baseUrl, enabled: true, credential: { action: 'replace', value: 'test-key' } })
|
||||
}
|
||||
|
||||
function bindingInput(instanceId: string, remoteKnowledgeBaseId = 'dataset') {
|
||||
return { instanceId, remoteKnowledgeBaseId, name: 'Handbook', remoteName: 'Remote handbook', testQuery: 'policy', commonConfig, providerConfig }
|
||||
}
|
||||
|
||||
function retrievalInput(instanceId: string) {
|
||||
return { instanceId, remoteKnowledgeBaseId: 'dataset', testQuery: 'policy', commonConfig, providerConfig }
|
||||
}
|
||||
|
||||
it('routes the real HTTP client through directory, detail, binding and scoped MCP without remote writes', async () => {
|
||||
const requests: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(`${request.method} ${request.url}`)
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.end(JSON.stringify(request.url?.includes('/retrieve') ? records() : request.url?.includes('?')
|
||||
? { data: [{ id: 'dataset', name: 'Handbook' }], has_more: false }
|
||||
: { id: 'dataset', name: 'Handbook' }))
|
||||
})
|
||||
servers.push(server)
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as { port: number }
|
||||
const { service } = await setup()
|
||||
const saved = instance(service, `http://127.0.0.1:${address.port}/v1`)
|
||||
expect(saved).not.toHaveProperty('credential')
|
||||
expect((await service.external.listCatalog({ instanceId: saved.id })).items).toHaveLength(1)
|
||||
expect(await service.external.getCatalog({ instanceId: saved.id, remoteKnowledgeBaseId: 'dataset' })).toMatchObject({ id: 'dataset' })
|
||||
const binding = await service.external.saveBinding(bindingInput(saved.id))
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
const token = gateway.grant('request', [binding.knowledgeBaseId], new AbortController().signal)!
|
||||
try {
|
||||
const references = await gateway.search(token, { query: 'policy' })
|
||||
expect(references).toHaveLength(1)
|
||||
expect(references[0]).toMatchObject({ external: { remoteDocumentId: 'remote-doc', remoteChunkId: 'chunk-0', providerScore: 0.8 } })
|
||||
expect(references[0]?.documentId).toBeUndefined()
|
||||
expect(service.database.listDocuments(binding.knowledgeBaseId)).toEqual([])
|
||||
expect(requests).toEqual(['GET /v1/datasets?page=1&limit=100', 'GET /v1/datasets/dataset', 'POST /v1/datasets/dataset/retrieve', 'POST /v1/datasets/dataset/retrieve'])
|
||||
} finally { await gateway.dispose() }
|
||||
})
|
||||
|
||||
it('keeps actual failures, bounds mixed retrieval to 48k, and rejects local mutations', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(records(20, 8000)))
|
||||
const { service } = await setup(fetcher)
|
||||
const saved = instance(service)
|
||||
const first = await service.external.saveBinding(bindingInput(saved.id))
|
||||
const second = await service.external.saveBinding(bindingInput(saved.id, 'second'))
|
||||
const results = await service.retrieveMany([first.knowledgeBaseId, second.knowledgeBaseId], 'policy')
|
||||
expect(results.flatMap(item => item.response.results).reduce((sum, result) => sum + result.snippet.length, 0)).toBe(48000)
|
||||
fetcher.mockResolvedValue(new Response('', { status: 401 }))
|
||||
const failed = await service.retrieveMany([first.knowledgeBaseId], 'policy')
|
||||
expect(failed[0]?.response.diagnostics.failure).toBe('EXTERNAL_KB_AUTH')
|
||||
await expect(service.importPaths(first.knowledgeBaseId, [])).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
|
||||
await expect(service.rebuildLibrary({ knowledgeBaseId: first.knowledgeBaseId })).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
|
||||
expect(() => service.updateSettings({ knowledgeBaseId: first.knowledgeBaseId, retrieval: service.database.getKnowledgeBase(first.knowledgeBaseId)!.retrievalSettings })).toThrow('EXTERNAL_KB_READ_ONLY')
|
||||
expect(() => service.database.updateKnowledgeBase(first.knowledgeBaseId, { graphEnabled: true })).toThrow('EXTERNAL_KB_READ_ONLY')
|
||||
})
|
||||
|
||||
it('rolls metadata back with binding failures and rejects concurrent duplicate creates', async () => {
|
||||
const { service, path } = await setup(async () => Response.json(records()))
|
||||
const saved = instance(service)
|
||||
const first = await service.external.saveBinding(bindingInput(saved.id))
|
||||
const inspection = new DatabaseSync(path)
|
||||
inspection.exec("CREATE TRIGGER reject_binding BEFORE UPDATE ON external_knowledge_bindings BEGIN SELECT RAISE(ABORT, 'binding failed'); END")
|
||||
try {
|
||||
await expect(service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: first.knowledgeBaseId, name: 'Changed' })).rejects.toThrow('binding failed')
|
||||
expect(service.database.getKnowledgeBase(first.knowledgeBaseId)?.name).toBe('Handbook')
|
||||
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ user_version: 12 })
|
||||
} finally { inspection.close() }
|
||||
const outcomes = await Promise.allSettled([service.external.saveBinding(bindingInput(saved.id, 'new')), service.external.saveBinding(bindingInput(saved.id, 'new'))])
|
||||
expect(outcomes.filter(item => item.status === 'fulfilled')).toHaveLength(1)
|
||||
expect(service.database.listKnowledgeBases()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels pending probes on disable and prevents shutdown writeback', async () => {
|
||||
let release!: (value: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>(() => new Promise<Response>(resolve => { release = resolve }))
|
||||
const { service } = await setup(fetcher)
|
||||
const saved = instance(service)
|
||||
const probe = service.external.testInstance(saved.id)
|
||||
service.external.setEnabled(saved.id, false)
|
||||
release(Response.json({ data: [], has_more: false }))
|
||||
await expect(probe).rejects.toThrow('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
expect(service.external.listInstances()[0]?.enabled).toBe(false)
|
||||
service.external.setEnabled(saved.id, true)
|
||||
const pending = service.external.saveBinding(bindingInput(saved.id))
|
||||
const rejected = expect(pending).rejects.toThrow()
|
||||
await service.dispose()
|
||||
services.splice(services.indexOf(service), 1)
|
||||
release(Response.json(records()))
|
||||
await rejected
|
||||
expect(() => service.external.listInstances()).toThrow('EXTERNAL_KB_CANCELLED')
|
||||
})
|
||||
|
||||
it('persists external locators without local IDs and distinguishes remote chunks', async () => {
|
||||
const { service } = await setup(async () => Response.json(records(2)))
|
||||
const binding = await service.external.saveBinding(bindingInput(instance(service).id))
|
||||
const response = await service.retrieve({ knowledgeBaseId: binding.knowledgeBaseId, query: 'policy' })
|
||||
const references = response.results.map(result => toKnowledgeReference(result, 'Handbook'))
|
||||
expect(knowledgeReferenceKey(references[0]!)).not.toBe(knowledgeReferenceKey(references[1]!))
|
||||
const message = conversationMessageSchema.parse({ id: crypto.randomUUID(), role: 'assistant', content: '', createdAt: 1, state: 'complete', sourceReferences: references })
|
||||
expect(message.sourceReferences?.[0]?.external).toEqual(references[0]?.external)
|
||||
})
|
||||
|
||||
it('reports unusable credentials and preserves the stored key when encryption fails', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(records()))
|
||||
const { service, cipher } = await setup(fetcher)
|
||||
const saved = instance(service)
|
||||
const before = service.database.externalStore.getInstance(saved.id)
|
||||
const decrypt = vi.spyOn(cipher, 'decrypt').mockImplementation(() => { throw new Error('OS key unavailable') })
|
||||
expect(service.external.listInstances()[0]?.credentialStatus).toBe('unavailable')
|
||||
await expect(service.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
decrypt.mockReturnValue(JSON.stringify(' '))
|
||||
expect(service.external.listInstances()[0]?.credentialStatus).toBe('unavailable')
|
||||
await expect(service.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
decrypt.mockRestore()
|
||||
vi.spyOn(cipher, 'encrypt').mockImplementation(() => { throw new Error('OS encryption failed') })
|
||||
expect(() => service.external.saveInstance({ id: saved.id, name: 'Changed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true,
|
||||
credential: { action: 'replace', value: 'new-key' } })).toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
expect(service.database.externalStore.getInstance(saved.id)).toEqual(before)
|
||||
expect(service.external.listInstances()[0]?.credentialStatus).toBe('configured')
|
||||
})
|
||||
|
||||
it('persists keep and clear credential mutations and normalized binding updates across restart', async () => {
|
||||
const { service, options } = await setup(async () => Response.json(records()))
|
||||
const saved = instance(service)
|
||||
const binding = await service.external.saveBinding(bindingInput(saved.id))
|
||||
const credential = service.database.externalStore.getInstance(saved.id)?.credential
|
||||
service.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'keep' } })
|
||||
const updated = await service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: ` ${binding.knowledgeBaseId} `, name: 'Updated' })
|
||||
expect(updated.knowledgeBaseId).toBe(binding.knowledgeBaseId)
|
||||
expect(service.database.externalStore.getInstance(saved.id)?.credential).toEqual(credential)
|
||||
await service.dispose()
|
||||
services.splice(services.indexOf(service), 1)
|
||||
const reopened = new KnowledgeService(options)
|
||||
services.push(reopened)
|
||||
await reopened.initialize()
|
||||
expect(reopened.external.listInstances()[0]).toMatchObject({ name: 'Renamed', credentialStatus: 'configured', bindingCount: 1 })
|
||||
expect(reopened.database.getKnowledgeBase(binding.knowledgeBaseId)?.name).toBe('Updated')
|
||||
expect(reopened.database.externalStore.listBindings()).toEqual([updated])
|
||||
await expect(reopened.external.testRetrieval(retrievalInput(saved.id))).resolves.toMatchObject({ results: [expect.anything()] })
|
||||
reopened.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'clear' } })
|
||||
expect(reopened.external.listInstances()[0]).toMatchObject({ credentialStatus: 'missing', bindingCount: 1 })
|
||||
const inspection = new DatabaseSync(options.databasePath)
|
||||
try {
|
||||
const row = inspection.prepare('SELECT value_json FROM external_knowledge_instances WHERE id=?').get(saved.id)!
|
||||
expect(JSON.parse(String(row.value_json))).not.toHaveProperty('credential')
|
||||
} finally { inspection.close() }
|
||||
await expect(reopened.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('does not invalidate retrieval or binding verification when a concurrent probe persists its result', async () => {
|
||||
const releases: ((response: Response) => void)[] = []
|
||||
const { service } = await setup(() => new Promise<Response>(resolve => releases.push(resolve)))
|
||||
const saved = instance(service)
|
||||
const binding = service.external.saveBinding(bindingInput(saved.id))
|
||||
const retrieval = service.external.testRetrieval(retrievalInput(saved.id))
|
||||
const probe = service.external.testInstance(saved.id)
|
||||
releases[2]!(Response.json({ data: [], has_more: false }))
|
||||
await expect(probe).resolves.toMatchObject({ probeStatus: 'catalog-ready' })
|
||||
releases[0]!(Response.json(records()))
|
||||
releases[1]!(Response.json(records()))
|
||||
await expect(binding).resolves.toMatchObject({ instanceId: saved.id })
|
||||
await expect(retrieval).resolves.toMatchObject({ results: [expect.anything()] })
|
||||
expect(service.external.listInstances()[0]?.probeStatus).toBe('catalog-ready')
|
||||
})
|
||||
|
||||
it('normalizes pre-request cancellation and persists network probe failures as unreachable', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => { throw new TypeError('fetch failed') })
|
||||
const { service } = await setup(fetcher)
|
||||
const saved = instance(service)
|
||||
await expect(service.external.testRetrieval(retrievalInput(saved.id), AbortSignal.abort())).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await expect(service.external.testInstance(saved.id)).resolves.toMatchObject({ probeStatus: 'unreachable', lastErrorCode: 'EXTERNAL_KB_NETWORK' })
|
||||
})
|
||||
|
||||
it('includes the RAGFlow capability check in the retrieval timeout budget', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async url => {
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
return Response.json(String(url).includes('/datasets')
|
||||
? { code: 0, data: [{ id: 'dataset', name: 'Graph', graphrag_task_finish_at: 1 }] }
|
||||
: { code: 0, data: { chunks: [] } })
|
||||
})
|
||||
const { service } = await setup(fetcher)
|
||||
const saved = service.external.saveInstance({ name: 'RAGFlow', provider: 'ragflow', baseUrl: 'https://kb.example', enabled: true, credential: { action: 'replace', value: 'test-key' } })
|
||||
await expect(service.external.testRetrieval({ ...retrievalInput(saved.id), providerConfig: {
|
||||
provider: 'ragflow', similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 10, useKg: true, includeKnowledgeCompilation: false
|
||||
} })).rejects.toMatchObject({ code: 'EXTERNAL_KB_TIMEOUT' })
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingTestInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeInstanceSaveInputSchema, type ExternalKnowledgeBinding, type ExternalKnowledgeBindingSaveInput, type ExternalKnowledgeBindingTestInput, type ExternalKnowledgeInstanceSaveInput, type ExternalKnowledgeInstanceSummary, type ExternalKnowledgeTestResult } from '../../../shared/external-knowledge-contracts'
|
||||
import { decryptSettingsCredential, encryptSettingsCredential, type SettingsCredentialCipher } from '../../settings-credential-cipher'
|
||||
import type { KnowledgeDatabase } from '../knowledge-database'
|
||||
import { ExternalKnowledgeClient, ExternalKnowledgeError } from './external-knowledge-client'
|
||||
import type { StoredExternalInstance } from './external-knowledge-store'
|
||||
|
||||
// Probe results are persisted metadata, not a change to an in-flight request's configuration.
|
||||
function instanceConfiguration(value: StoredExternalInstance): string {
|
||||
return JSON.stringify([value.id, value.name, value.provider, value.baseUrl, value.enabled, value.credential])
|
||||
}
|
||||
|
||||
export class ExternalKnowledgeService {
|
||||
private disposed = false
|
||||
private readonly controllers = new Map<AbortController,string>()
|
||||
constructor(private readonly database: KnowledgeDatabase, private readonly cipher?: SettingsCredentialCipher, private readonly fetcher?: typeof fetch) {}
|
||||
dispose(): void { this.disposed = true; for (const controller of this.controllers.keys()) controller.abort(); this.controllers.clear() }
|
||||
private checkActive(): void { if (this.disposed) throw new Error('EXTERNAL_KB_CANCELLED') }
|
||||
private cancelInstance(id: string): void {
|
||||
for (const [controller, instanceId] of this.controllers) if (instanceId === id) controller.abort()
|
||||
}
|
||||
private instance(id:string): StoredExternalInstance {
|
||||
this.checkActive()
|
||||
const value=this.database.externalStore.getInstance(id)
|
||||
if (!value) throw new Error('EXTERNAL_KB_NOT_FOUND')
|
||||
return value
|
||||
}
|
||||
listInstances(): ExternalKnowledgeInstanceSummary[] {
|
||||
this.checkActive()
|
||||
const bindings=this.database.externalStore.listBindings()
|
||||
return this.database.externalStore.listInstances().map(({credential,...value})=>{
|
||||
let credentialStatus: ExternalKnowledgeInstanceSummary['credentialStatus'] = 'missing'
|
||||
if (credential) {
|
||||
try { this.credential({ ...value, credential }); credentialStatus = 'configured' }
|
||||
catch { credentialStatus = 'unavailable' }
|
||||
}
|
||||
return {...value,credentialStatus,bindingCount:bindings.filter(binding=>binding.instanceId===value.id).length}
|
||||
})
|
||||
}
|
||||
private credential(instance: StoredExternalInstance): string {
|
||||
try {
|
||||
if (!instance.credential || !this.cipher?.isAvailable()) throw new Error()
|
||||
const key = decryptSettingsCredential(this.cipher, instance.credential)
|
||||
if (typeof key !== 'string' || !key.trim()) throw new Error()
|
||||
return key
|
||||
} catch { throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE') }
|
||||
}
|
||||
saveInstance(raw:ExternalKnowledgeInstanceSaveInput):ExternalKnowledgeInstanceSummary {
|
||||
this.checkActive()
|
||||
const input=externalKnowledgeInstanceSaveInputSchema.parse(raw)
|
||||
const previous=input.id ? this.instance(input.id):undefined
|
||||
if (previous && previous.provider!==input.provider && this.database.externalStore.listBindings().some(binding=>binding.instanceId===previous.id)) throw new Error('EXTERNAL_KB_INSTANCE_IN_USE')
|
||||
let credential=previous?.credential
|
||||
if (input.credential.action==='clear') credential=undefined
|
||||
if (input.credential.action==='replace') {
|
||||
if (!this.cipher?.isAvailable()) throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
|
||||
try { credential=encryptSettingsCredential(this.cipher,input.credential.value) }
|
||||
catch { throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE') }
|
||||
}
|
||||
const value:StoredExternalInstance={id:previous?.id??randomUUID(),name:input.name,provider:input.provider,baseUrl:input.baseUrl.replace(/\/+$/,''),enabled:input.enabled,credential,probeStatus:'untested'}
|
||||
this.database.externalStore.saveInstance(value)
|
||||
this.cancelInstance(value.id)
|
||||
return this.listInstances().find(item=>item.id===value.id)!
|
||||
}
|
||||
setEnabled(id:string,enabled:boolean):ExternalKnowledgeInstanceSummary {
|
||||
const instance=this.instance(id)
|
||||
this.database.externalStore.saveInstance({...instance,enabled})
|
||||
if (!enabled) for(const [controller,instanceId] of this.controllers) if(instanceId===id) controller.abort()
|
||||
return this.listInstances().find(item=>item.id===id)!
|
||||
}
|
||||
deleteInstance(id:string):void {
|
||||
this.instance(id)
|
||||
if(this.database.externalStore.listBindings().some(binding=>binding.instanceId===id)) throw new Error('EXTERNAL_KB_INSTANCE_IN_USE')
|
||||
for(const [controller,instanceId] of this.controllers) if(instanceId===id) controller.abort()
|
||||
this.database.externalStore.deleteInstance(id)
|
||||
}
|
||||
private async request<T>(id:string,timeout:number,signal:AbortSignal|undefined,operation:(client:ExternalKnowledgeClient,signal:AbortSignal)=>Promise<T>):Promise<T> {
|
||||
const instance=this.instance(id)
|
||||
if(!instance.enabled) throw new Error('EXTERNAL_KB_DISABLED')
|
||||
const key = this.credential(instance)
|
||||
const controller=new AbortController()
|
||||
this.controllers.set(controller,id)
|
||||
const timeoutSignal = AbortSignal.timeout(timeout)
|
||||
const combined = AbortSignal.any([controller.signal, timeoutSignal, ...(signal ? [signal] : [])])
|
||||
try {
|
||||
combined.throwIfAborted()
|
||||
const result = await operation(new ExternalKnowledgeClient({provider:instance.provider,baseUrl:instance.baseUrl,apiKey:key,timeoutMs:timeout,fetcher:this.fetcher}),combined)
|
||||
combined.throwIfAborted()
|
||||
return result
|
||||
} catch (error) {
|
||||
if (combined.aborted) {
|
||||
const timedOut = timeoutSignal.aborted && combined.reason === timeoutSignal.reason
|
||||
throw new ExternalKnowledgeError(timedOut ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_CANCELLED',
|
||||
timedOut ? 'External knowledge request timed out' : 'External knowledge request was cancelled')
|
||||
}
|
||||
throw error
|
||||
} finally {this.controllers.delete(controller)}
|
||||
}
|
||||
async testInstance(id:string):Promise<ExternalKnowledgeInstanceSummary> {
|
||||
const instance=this.instance(id)
|
||||
let probeStatus:StoredExternalInstance['probeStatus']='catalog-ready'
|
||||
let lastErrorCode:string|undefined
|
||||
try {await this.listCatalog({instanceId:id})} catch(error) {
|
||||
lastErrorCode=error instanceof ExternalKnowledgeError ? error.code : error instanceof Error && /^EXTERNAL_KB_/.test(error.message) ? error.message : 'EXTERNAL_KB_NETWORK'
|
||||
probeStatus=lastErrorCode==='EXTERNAL_KB_FORBIDDEN'?'list-restricted':lastErrorCode==='EXTERNAL_KB_AUTH'?'auth-failed':lastErrorCode==='EXTERNAL_KB_NETWORK'||lastErrorCode==='EXTERNAL_KB_TIMEOUT'?'unreachable':'failed'
|
||||
}
|
||||
const current = this.instance(id)
|
||||
if (instanceConfiguration(current) !== instanceConfiguration(instance)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
this.database.externalStore.saveInstance({...current,probeStatus,lastErrorCode,lastTestedAt:new Date().toISOString()})
|
||||
return this.listInstances().find(item=>item.id===id)!
|
||||
}
|
||||
listCatalog(input:{instanceId:string;page?:number;pageSize?:number;parentId?:string|null;search?:string},signal?:AbortSignal) {
|
||||
return this.request(input.instanceId,15000,signal,(client,current)=>client.listKnowledgeBases(current,input))
|
||||
}
|
||||
getCatalog(input:{instanceId:string;remoteKnowledgeBaseId:string},signal?:AbortSignal) {
|
||||
return this.request(input.instanceId,15000,signal,(client,current)=>client.getKnowledgeBase(input.remoteKnowledgeBaseId,current))
|
||||
}
|
||||
async testRetrieval(raw:ExternalKnowledgeBindingTestInput,signal?:AbortSignal):Promise<ExternalKnowledgeTestResult> {
|
||||
const input=externalKnowledgeBindingTestInputSchema.parse(raw)
|
||||
const instance=this.instance(input.instanceId)
|
||||
if(instance.provider!==input.providerConfig.provider) throw new Error('EXTERNAL_KB_CONFIG_INVALID')
|
||||
const started=Date.now()
|
||||
const results=await this.request(input.instanceId,input.commonConfig.requestTimeoutMs,signal,async (client,current)=>{
|
||||
if(input.providerConfig.provider==='ragflow' && (input.providerConfig.useKg||input.providerConfig.includeKnowledgeCompilation)) {
|
||||
const detail=await client.getKnowledgeBase(input.remoteKnowledgeBaseId,current)
|
||||
if((input.providerConfig.useKg&&!detail.graphEnabled)||(input.providerConfig.includeKnowledgeCompilation&&!detail.knowledgeCompilationEnabled)) throw new Error('EXTERNAL_KB_CAPABILITY_UNAVAILABLE')
|
||||
}
|
||||
return client.retrieve(input.remoteKnowledgeBaseId,input.testQuery,input.providerConfig,current)
|
||||
})
|
||||
if (instanceConfiguration(instance) !== instanceConfiguration(this.instance(input.instanceId))) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
let remaining = 48_000
|
||||
const bounded = results.slice(0, input.commonConfig.resultLimit).map(item => {
|
||||
const snippet = item.snippet.slice(0, Math.min(remaining, input.commonConfig.maxSnippetCharacters))
|
||||
remaining -= snippet.length
|
||||
return { ...item, snippet }
|
||||
}).filter(item => item.snippet.length > 0)
|
||||
return {results:bounded,durationMs:Date.now()-started}
|
||||
}
|
||||
async saveBinding(raw:ExternalKnowledgeBindingSaveInput & {knowledgeBaseId?:string}):Promise<ExternalKnowledgeBinding> {
|
||||
this.checkActive()
|
||||
const input=raw.knowledgeBaseId !== undefined ? externalKnowledgeBindingUpdateInputSchema.parse(raw):externalKnowledgeBindingSaveInputSchema.parse(raw)
|
||||
const knowledgeBaseId = 'knowledgeBaseId' in input ? input.knowledgeBaseId : undefined
|
||||
const existing=knowledgeBaseId ? this.database.externalStore.listBindings().find(item=>item.knowledgeBaseId===knowledgeBaseId):undefined
|
||||
if(knowledgeBaseId&&!existing) throw new Error('EXTERNAL_KB_NOT_FOUND')
|
||||
if(this.database.externalStore.listBindings().some(item=>item.instanceId===input.instanceId&&item.remoteKnowledgeBaseId===input.remoteKnowledgeBaseId&&item.knowledgeBaseId!==existing?.knowledgeBaseId)) throw new Error('EXTERNAL_KB_DUPLICATE_BINDING')
|
||||
const instance = this.instance(input.instanceId)
|
||||
await this.testRetrieval({instanceId:input.instanceId,remoteKnowledgeBaseId:input.remoteKnowledgeBaseId,commonConfig:input.commonConfig,providerConfig:input.providerConfig,testQuery:input.testQuery})
|
||||
if (instanceConfiguration(instance) !== instanceConfiguration(this.instance(input.instanceId))) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
const bindings = this.database.externalStore.listBindings()
|
||||
if (existing && JSON.stringify(bindings.find(item => item.knowledgeBaseId === existing.knowledgeBaseId)) !== JSON.stringify(existing)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
if (bindings.some(item => item.instanceId === input.instanceId && item.remoteKnowledgeBaseId === input.remoteKnowledgeBaseId && item.knowledgeBaseId !== existing?.knowledgeBaseId)) throw new Error('EXTERNAL_KB_DUPLICATE_BINDING')
|
||||
return this.database.saveExternalBinding({instanceId:input.instanceId,provider:input.providerConfig.provider,remoteKnowledgeBaseId:input.remoteKnowledgeBaseId,remoteName:input.remoteName,commonConfig:input.commonConfig,providerConfig:input.providerConfig,lastVerifiedAt:new Date().toISOString()}, {name:input.name,description:input.description,knowledgeBaseId:existing?.knowledgeBaseId})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import type { ExternalKnowledgeBinding, ExternalKnowledgeInstanceSummary } from '../../../shared/external-knowledge-contracts'
|
||||
import type { EncryptedSettingsCredential } from '../../settings-credential-cipher'
|
||||
|
||||
export type StoredExternalInstance = Omit<ExternalKnowledgeInstanceSummary, 'credentialStatus' | 'bindingCount'> & {credential?:EncryptedSettingsCredential}
|
||||
|
||||
export class ExternalKnowledgeStore {
|
||||
constructor(private readonly database: DatabaseSync) {}
|
||||
getInstance(id: string): StoredExternalInstance | undefined {
|
||||
const row = this.database.prepare('SELECT value_json FROM external_knowledge_instances WHERE id=?').get(id)
|
||||
return row ? JSON.parse(String(row.value_json)) as StoredExternalInstance : undefined
|
||||
}
|
||||
listInstances(): StoredExternalInstance[] {
|
||||
return this.database.prepare('SELECT value_json FROM external_knowledge_instances ORDER BY rowid').all().map(row => JSON.parse(String(row.value_json)) as StoredExternalInstance)
|
||||
}
|
||||
saveInstance(value: StoredExternalInstance): void {
|
||||
this.database.prepare('INSERT INTO external_knowledge_instances(id,value_json) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET value_json=excluded.value_json').run(value.id, JSON.stringify(value))
|
||||
}
|
||||
deleteInstance(id: string): void {
|
||||
this.database.prepare('DELETE FROM external_knowledge_instances WHERE id=?').run(id)
|
||||
}
|
||||
listBindings(): ExternalKnowledgeBinding[] {
|
||||
return this.database.prepare('SELECT value_json FROM external_knowledge_bindings ORDER BY rowid').all().map(row => JSON.parse(String(row.value_json)) as ExternalKnowledgeBinding)
|
||||
}
|
||||
saveBinding(value: ExternalKnowledgeBinding): void {
|
||||
this.database.prepare('INSERT INTO external_knowledge_bindings(knowledge_base_id,instance_id,remote_id,value_json) VALUES (?,?,?,?) ON CONFLICT(knowledge_base_id) DO UPDATE SET instance_id=excluded.instance_id,remote_id=excluded.remote_id,value_json=excluded.value_json').run(value.knowledgeBaseId,value.instanceId,value.remoteKnowledgeBaseId,JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ describe('KnowledgeDatabase', () => {
|
||||
const inspection = new DatabaseSync(path)
|
||||
expect(
|
||||
inspection.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 11 })
|
||||
).toEqual({ user_version: 12 })
|
||||
expect(
|
||||
inspection
|
||||
.prepare('SELECT version FROM schema_migrations ORDER BY version')
|
||||
@@ -102,7 +102,8 @@ describe('KnowledgeDatabase', () => {
|
||||
{ version: 8 },
|
||||
{ version: 9 },
|
||||
{ version: 10 },
|
||||
{ version: 11 }
|
||||
{ version: 11 },
|
||||
{ version: 12 }
|
||||
])
|
||||
inspection.close()
|
||||
|
||||
@@ -143,14 +144,14 @@ describe('KnowledgeDatabase', () => {
|
||||
expect(repaired.listDocuments(knowledgeBase.id)).toHaveLength(1)
|
||||
const inspection = new DatabaseSync(path)
|
||||
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 11
|
||||
user_version: 12
|
||||
})
|
||||
inspection.close()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['migration version', 'INSERT INTO schema_migrations VALUES (12, ?)', true],
|
||||
['user version', 'PRAGMA user_version = 12', false]
|
||||
['migration version', 'INSERT INTO schema_migrations VALUES (13, ?)', true],
|
||||
['user version', 'PRAGMA user_version = 13', false]
|
||||
])('rejects a future %s without downgrading it', async (
|
||||
_label,
|
||||
statement,
|
||||
@@ -168,18 +169,18 @@ describe('KnowledgeDatabase', () => {
|
||||
|
||||
const unsupported = new KnowledgeDatabase(path)
|
||||
expect(() => unsupported.initialize()).toThrow(
|
||||
'newer than supported version 11'
|
||||
'newer than supported version 12'
|
||||
)
|
||||
|
||||
const inspection = new DatabaseSync(path)
|
||||
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: hasParameter ? 11 : 12
|
||||
user_version: hasParameter ? 12 : 13
|
||||
})
|
||||
expect(
|
||||
inspection
|
||||
.prepare('SELECT MAX(version) AS version FROM schema_migrations')
|
||||
.get()
|
||||
).toEqual({ version: hasParameter ? 12 : 11 })
|
||||
).toEqual({ version: hasParameter ? 13 : 12 })
|
||||
inspection.close()
|
||||
})
|
||||
|
||||
@@ -319,7 +320,7 @@ describe('KnowledgeDatabase', () => {
|
||||
DROP TABLE chunk_embeddings;
|
||||
DROP TABLE knowledge_tasks;
|
||||
DELETE FROM schema_migrations
|
||||
WHERE version IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
|
||||
WHERE version > 1;
|
||||
PRAGMA user_version = 1;
|
||||
`)
|
||||
downgrade.close()
|
||||
@@ -329,7 +330,7 @@ describe('KnowledgeDatabase', () => {
|
||||
upgraded.initialize()
|
||||
const inspection = new DatabaseSync(path)
|
||||
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 11
|
||||
user_version: 12
|
||||
})
|
||||
expect(
|
||||
inspection
|
||||
@@ -436,7 +437,7 @@ describe('KnowledgeDatabase', () => {
|
||||
WHERE new.enabled = 1 AND new.role <> 'parent';
|
||||
END;
|
||||
INSERT INTO chunks_fts(chunks_fts) VALUES ('rebuild');
|
||||
DELETE FROM schema_migrations WHERE version IN (9, 10, 11);
|
||||
DELETE FROM schema_migrations WHERE version > 8;
|
||||
PRAGMA user_version = 8;
|
||||
`)
|
||||
downgrade
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { ExternalKnowledgeStore } from './external/external-knowledge-store'
|
||||
import { DatabaseSync, type StatementSync } from 'node:sqlite'
|
||||
import {
|
||||
embeddingIndexJobSchema,
|
||||
@@ -80,7 +81,7 @@ import type {
|
||||
VectorSearchOptions
|
||||
} from './types'
|
||||
|
||||
const DATABASE_VERSION = 11
|
||||
const DATABASE_VERSION = 12
|
||||
const MAX_ID_LENGTH = 128
|
||||
const MAX_NAME_LENGTH = 512
|
||||
const MAX_LOCATION_LENGTH = 8192
|
||||
@@ -739,6 +740,7 @@ function mapKnowledgeTask(row: Row): KnowledgeTaskItem {
|
||||
}
|
||||
|
||||
export class KnowledgeDatabase {
|
||||
externalStore!: ExternalKnowledgeStore
|
||||
private database?: DatabaseSync
|
||||
private readonly taskPrunedLibraries = new Set<string>()
|
||||
private readonly taskPrunePending = new Set<string>()
|
||||
@@ -765,6 +767,7 @@ export class KnowledgeDatabase {
|
||||
this.assertFts5(database)
|
||||
this.migrate(database)
|
||||
this.database = database
|
||||
this.externalStore = new ExternalKnowledgeStore(database)
|
||||
this.interruptActiveKnowledgeTasks()
|
||||
database
|
||||
.prepare('DELETE FROM embedding_rebuild_staging')
|
||||
@@ -903,11 +906,30 @@ export class KnowledgeDatabase {
|
||||
return row ? mapKnowledgeBase(row) : undefined
|
||||
}
|
||||
|
||||
saveExternalBinding(
|
||||
binding: Omit<import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding, 'knowledgeBaseId'>,
|
||||
metadata: { name: string; description?: string; knowledgeBaseId?: string }
|
||||
): import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding {
|
||||
let saved!: import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding
|
||||
this.transaction(this.requireDatabase(), () => {
|
||||
const library = metadata.knowledgeBaseId
|
||||
? this.updateKnowledgeBase(metadata.knowledgeBaseId, { name: metadata.name, description: metadata.description })
|
||||
: this.createKnowledgeBase({ name: metadata.name, description: metadata.description, storageMode: 'reference', graphEnabled: false })
|
||||
saved = { ...binding, knowledgeBaseId: library.id }
|
||||
this.externalStore.saveBinding(saved)
|
||||
})
|
||||
return saved
|
||||
}
|
||||
|
||||
updateKnowledgeBase(
|
||||
id: string,
|
||||
input: UpdateKnowledgeBaseInput
|
||||
): KnowledgeBase {
|
||||
const current = this.requiredKnowledgeBase(id)
|
||||
if (this.externalStore.listBindings().some(item => item.knowledgeBaseId === id) &&
|
||||
(input.graphEnabled !== undefined || input.graphStrategy !== undefined || input.storageMode !== undefined)) {
|
||||
throw new Error('EXTERNAL_KB_READ_ONLY')
|
||||
}
|
||||
const name =
|
||||
input.name === undefined
|
||||
? current.name
|
||||
@@ -4587,6 +4609,19 @@ export class KnowledgeDatabase {
|
||||
)
|
||||
.run(11, new Date().toISOString())
|
||||
}
|
||||
if (currentVersion < 12) {
|
||||
database.exec(`CREATE TABLE IF NOT EXISTS external_knowledge_instances (
|
||||
id TEXT PRIMARY KEY, value_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS external_knowledge_bindings (
|
||||
knowledge_base_id TEXT PRIMARY KEY REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
instance_id TEXT NOT NULL REFERENCES external_knowledge_instances(id) ON DELETE RESTRICT,
|
||||
remote_id TEXT NOT NULL, value_json TEXT NOT NULL,
|
||||
UNIQUE(instance_id, remote_id)
|
||||
);`)
|
||||
database.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
|
||||
.run(12, new Date().toISOString())
|
||||
}
|
||||
database.exec(`PRAGMA user_version = ${DATABASE_VERSION}`)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
|
||||
@@ -1113,6 +1113,42 @@ describe('KnowledgeService', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('retains ranked evidence from each external library within the shared budget', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-service-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const service = new KnowledgeService({
|
||||
databasePath: join(directory, 'knowledge.sqlite'),
|
||||
managedRoot: join(directory, 'managed'),
|
||||
credentialCipher: {
|
||||
isAvailable: () => true,
|
||||
encrypt: value => Buffer.from(value),
|
||||
decrypt: value => value.toString()
|
||||
},
|
||||
externalFetcher: async () => Response.json({ records: Array.from({ length: 6 }, (_, index) => ({
|
||||
segment: { id: `chunk-${index}`, content: 'e'.repeat(8000), document: { id: 'document', name: 'Handbook' } }
|
||||
})) })
|
||||
})
|
||||
services.push(service)
|
||||
await service.initialize()
|
||||
const instance = service.external.saveInstance({
|
||||
name: 'Test', provider: 'dify', baseUrl: 'https://kb.example', enabled: true,
|
||||
credential: { action: 'replace', value: 'test-key' }
|
||||
})
|
||||
const bindings = []
|
||||
for (const remoteKnowledgeBaseId of ['first', 'second']) {
|
||||
bindings.push(await service.external.saveBinding({
|
||||
instanceId: instance.id, remoteKnowledgeBaseId, name: remoteKnowledgeBaseId,
|
||||
remoteName: remoteKnowledgeBaseId, testQuery: 'policy',
|
||||
commonConfig: { resultLimit: 6, requestTimeoutMs: 1000, maxSnippetCharacters: 8000 },
|
||||
providerConfig: { provider: 'dify', useDatasetDefaults: true }
|
||||
}))
|
||||
}
|
||||
const outcomes = await service.retrieveMany(bindings.map(binding => binding.knowledgeBaseId), 'policy')
|
||||
expect(outcomes.map(({ response }) => response.results.map(result => result.rank))).toEqual([[1, 2, 3], [1, 2, 3]])
|
||||
expect(outcomes.map(({ response }) => response.context.characterCount)).toEqual([24000, 24000])
|
||||
expect(outcomes.every(({ response }) => response.context.truncated)).toBe(true)
|
||||
})
|
||||
|
||||
it('reranks multiple libraries concurrently while preserving input order', async () => {
|
||||
let releaseReranks: (() => void) | undefined
|
||||
let startedReranks = 0
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ExternalKnowledgeService } from './external/external-knowledge-service'
|
||||
import { ExternalKnowledgeError } from './external/external-knowledge-client'
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
@@ -144,6 +146,8 @@ export type KnowledgeSnapshot = {
|
||||
}
|
||||
|
||||
export type KnowledgeServiceOptions = {
|
||||
credentialCipher?: import('../settings-credential-cipher').SettingsCredentialCipher
|
||||
externalFetcher?: typeof fetch
|
||||
databasePath: string
|
||||
managedRoot: string
|
||||
extractStructured?: ExtractStructured
|
||||
@@ -208,6 +212,7 @@ function embedKnowledgeQuery(
|
||||
}
|
||||
|
||||
export class KnowledgeService {
|
||||
readonly external: ExternalKnowledgeService
|
||||
readonly database: KnowledgeDatabase
|
||||
private readonly managedRoot: string
|
||||
private readonly extractStructured?: ExtractStructured
|
||||
@@ -255,6 +260,7 @@ export class KnowledgeService {
|
||||
|
||||
constructor(options: KnowledgeServiceOptions) {
|
||||
this.database = new KnowledgeDatabase(options.databasePath)
|
||||
this.external = new ExternalKnowledgeService(this.database, options.credentialCipher, options.externalFetcher)
|
||||
this.managedRoot = resolve(options.managedRoot)
|
||||
this.extractStructured = options.extractStructured
|
||||
this.urlImporter = options.urlImporter ?? new UrlImporter()
|
||||
@@ -293,6 +299,7 @@ export class KnowledgeService {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.external.dispose()
|
||||
this.lifecycleController.abort(
|
||||
new Error('Knowledge service is shutting down')
|
||||
)
|
||||
@@ -440,6 +447,7 @@ export class KnowledgeService {
|
||||
private async getEmbeddingIndexCoordinator(
|
||||
knowledgeBaseId: string
|
||||
): Promise<EmbeddingIndexCoordinator> {
|
||||
this.requireLibrary(knowledgeBaseId)
|
||||
const existing = this.embeddingIndexCoordinators.get(knowledgeBaseId)
|
||||
if (existing) {
|
||||
return existing
|
||||
@@ -1234,9 +1242,23 @@ export class KnowledgeService {
|
||||
signal?: AbortSignal,
|
||||
preparedQueryEmbedding?: PreparedQueryEmbedding
|
||||
): Promise<KnowledgeRetrievalResponse> {
|
||||
signal = signal ? AbortSignal.any([signal, this.lifecycleController.signal]) : this.lifecycleController.signal
|
||||
signal.throwIfAborted()
|
||||
const input = knowledgeRetrieveInputSchema.parse(rawInput)
|
||||
const library = this.requireLibrary(input.knowledgeBaseId)
|
||||
const library = this.requireLibrary(input.knowledgeBaseId, false)
|
||||
const settings = input.settings ?? library.retrievalSettings
|
||||
const binding = this.database.externalStore.listBindings().find(item => item.knowledgeBaseId === library.id)
|
||||
if (binding) {
|
||||
const response = await this.external.testRetrieval({instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId,commonConfig:binding.commonConfig,providerConfig:binding.providerConfig,testQuery:input.query}, signal)
|
||||
signal.throwIfAborted()
|
||||
if (JSON.stringify(this.database.externalStore.listBindings().find(item => item.knowledgeBaseId === library.id)) !== JSON.stringify(binding)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
|
||||
return {
|
||||
query:input.query,durationMs:response.durationMs,settings,
|
||||
diagnostics:{external:{provider:binding.provider,instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId},requestedChannels:[],usedChannels:[],degradedChannels:[],candidateCounts:{},channelDurationMs:{},vectorScannedCount:0,filteredByThresholdCount:0,filteredByBudgetCount:0,rerank:{requested:'none',used:'none',status:'skipped',candidateCount:0,durationMs:0}},
|
||||
results:response.results.map((item,index)=>({knowledgeBaseId:library.id,documentTitle:item.documentTitle,sourceDisplayName:item.sourceDisplayName,snippet:item.snippet,rank:index+1,channels:[],scores:{fusedScore:0},external:{kind:'external' as const,provider:binding.provider,instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId,remoteDocumentId:item.remoteDocumentId,remoteChunkId:item.remoteChunkId,providerScore:item.providerScore,providerScores:item.providerScores,location:item.location,sourceUrl:item.sourceUrl}})),
|
||||
context:{characterCount:response.results.reduce((sum,item)=>sum+item.snippet.length,0),truncated:false,groups:[]}
|
||||
}
|
||||
}
|
||||
const hasHanQuery = containsHanText(input.query)
|
||||
const startedAt = Date.now()
|
||||
const requestedChannels: Array<'fts' | 'cjk' | 'vector' | 'graph'> = []
|
||||
@@ -1665,32 +1687,64 @@ export class KnowledgeService {
|
||||
response: KnowledgeRetrievalResponse
|
||||
}>
|
||||
> {
|
||||
const libraries = knowledgeBaseIds.map((id) =>
|
||||
this.requireLibrary(id)
|
||||
signal = signal ? AbortSignal.any([signal, this.lifecycleController.signal]) : this.lifecycleController.signal
|
||||
signal.throwIfAborted()
|
||||
const libraries = [...new Set(knowledgeBaseIds)].map((id) =>
|
||||
this.requireLibrary(id, false)
|
||||
)
|
||||
const preparedEmbedding = libraries.some(
|
||||
(library) => library.retrievalSettings.vectorWeight > 0
|
||||
(library) => !this.database.externalStore.listBindings().some(item=>item.knowledgeBaseId===library.id) && library.retrievalSettings.vectorWeight > 0
|
||||
)
|
||||
? await this.prepareQueryEmbedding(query, signal)
|
||||
: undefined
|
||||
signal?.throwIfAborted()
|
||||
return Promise.all(
|
||||
libraries.map(async (library) => ({
|
||||
knowledgeBaseId: library.id,
|
||||
response: await this.retrieve(
|
||||
const outcomes = await Promise.all(
|
||||
libraries.map(async (library) => {
|
||||
try { return { knowledgeBaseId: library.id, response: await this.retrieve(
|
||||
{
|
||||
knowledgeBaseId: library.id,
|
||||
query
|
||||
},
|
||||
signal,
|
||||
preparedEmbedding
|
||||
)
|
||||
}))
|
||||
)} } catch(error) {
|
||||
signal?.throwIfAborted()
|
||||
const failure=error instanceof ExternalKnowledgeError ? error.code : error instanceof Error && /^EXTERNAL_KB_[A-Z_]+$/.test(error.message) ? error.message : 'Knowledge retrieval failed'
|
||||
return {knowledgeBaseId:library.id,response:{query,durationMs:0,settings:library.retrievalSettings,diagnostics:{failure,requestedChannels:[],usedChannels:[],degradedChannels:[],candidateCounts:{},channelDurationMs:{},vectorScannedCount:0,filteredByThresholdCount:0,filteredByBudgetCount:0,rerank:{requested:'none',used:'none',status:'skipped',candidateCount:0,durationMs:0}},results:[],context:{characterCount:0,truncated:false,groups:[]}} as KnowledgeRetrievalResponse}
|
||||
}
|
||||
})
|
||||
)
|
||||
let remaining = 48_000
|
||||
let remainingContext = 48_000
|
||||
// Spend the shared budget by rank so one library cannot crowd out later libraries.
|
||||
const ranked = outcomes.flatMap(({ response }) =>
|
||||
response.results.map(result => ({ response, result }))
|
||||
).sort((left, right) => left.result.rank - right.result.rank)
|
||||
for (const { response, result } of ranked) {
|
||||
const snippet = result.snippet.slice(0, remaining)
|
||||
if (snippet.length < result.snippet.length) response.context.truncated = true
|
||||
result.snippet = snippet
|
||||
remaining -= snippet.length
|
||||
const group = response.context.groups.find(item => item.resultChunkId === result.chunkId)
|
||||
if (!group) continue
|
||||
const content = group.content.slice(0, remainingContext)
|
||||
if (content.length < group.content.length) response.context.truncated = true
|
||||
group.content = content
|
||||
remainingContext -= content.length
|
||||
}
|
||||
for (const { response } of outcomes) {
|
||||
response.results = response.results.filter(result => result.snippet.length > 0)
|
||||
response.context.groups = response.context.groups.filter(group => group.content.length > 0)
|
||||
response.context.characterCount = response.context.groups.length
|
||||
? response.context.groups.reduce((sum, group) => sum + group.content.length, 0)
|
||||
: response.results.reduce((sum, result) => sum + result.snippet.length, 0)
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
updateSettings(rawInput: KnowledgeSettingsUpdateInput): KnowledgeBase {
|
||||
const input = knowledgeSettingsUpdateInputSchema.parse(rawInput)
|
||||
this.requireLibrary(input.knowledgeBaseId)
|
||||
return this.database.updateKnowledgeSettings(input)
|
||||
}
|
||||
|
||||
@@ -1704,6 +1758,7 @@ export class KnowledgeService {
|
||||
rawInput: KnowledgeChunkUpdateInput
|
||||
): Promise<ReturnType<KnowledgeDatabase['updateChunk']>> {
|
||||
const input = knowledgeChunkUpdateInputSchema.parse(rawInput)
|
||||
this.requireLibrary(input.knowledgeBaseId)
|
||||
return this.withDocumentMutation(input.documentId, async () => {
|
||||
const current = this.database.getChunkForReference(
|
||||
input.knowledgeBaseId,
|
||||
@@ -1733,6 +1788,7 @@ export class KnowledgeService {
|
||||
|
||||
async deleteChunk(rawInput: KnowledgeChunkDeleteInput): Promise<boolean> {
|
||||
const input = knowledgeChunkDeleteInputSchema.parse(rawInput)
|
||||
this.requireLibrary(input.knowledgeBaseId)
|
||||
return this.withDocumentMutation(input.documentId, async () => {
|
||||
const deleted = this.database.deleteChunk(input)
|
||||
if (deleted) {
|
||||
@@ -3806,7 +3862,10 @@ export class KnowledgeService {
|
||||
: candidate
|
||||
}
|
||||
|
||||
private requireLibrary(id: string): KnowledgeBase {
|
||||
private requireLibrary(id: string, localOnly = true): KnowledgeBase {
|
||||
if (localOnly && this.database.externalStore.listBindings().some(item => item.knowledgeBaseId === id)) {
|
||||
throw new Error('EXTERNAL_KB_READ_ONLY')
|
||||
}
|
||||
const library = this.database.getKnowledgeBase(id)
|
||||
if (!library) {
|
||||
throw new Error('知识库不存在')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { remoteQuestionResponseSchema } from '../../shared/remote-question-contracts'
|
||||
import { workspaceManagementResultSchema } from '../../shared/workspace-management-contracts'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
@@ -187,6 +188,10 @@ export const AGENT_PROTOCOL_METHODS = {
|
||||
params: remoteOwnedPromptAttachRequestSchema,
|
||||
result: remoteOwnedPromptStartResultSchema
|
||||
},
|
||||
'runtime/respondToQuestion': {
|
||||
params: remoteQuestionResponseSchema,
|
||||
result: z.object({}).strict()
|
||||
},
|
||||
'runtime/pagePromptTranscript': {
|
||||
params: remoteSemanticTranscriptPageRequestSchema,
|
||||
result: remoteSemanticTranscriptPageResultSchema
|
||||
|
||||
@@ -453,6 +453,10 @@ class ManagedRemoteAcpRuntime implements AgentRuntime {
|
||||
return this.remote.getStatus()
|
||||
}
|
||||
|
||||
respondToQuestion(questionId: string, answers: string[][] = []): Promise<void> {
|
||||
return this.remote.respondToQuestion(questionId, answers)
|
||||
}
|
||||
|
||||
run(
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
|
||||
@@ -489,6 +489,17 @@ async function openChannel(fixture = connectionFixture()) {
|
||||
}
|
||||
|
||||
describe('ProtocolRemoteRuntimeChannel', () => {
|
||||
it('sends question replies on the authenticated control channel and rejects foreign bindings', async () => {
|
||||
const fixture = await openChannel()
|
||||
const response = { bindingId: 'binding-1', operationId: 'operation-1', questionId: 'question-1', answers: [] }
|
||||
await expect(fixture.channel.respondToQuestion({ ...response, bindingId: 'other' })).rejects.toThrow('binding identity')
|
||||
await fixture.channel.respondToQuestion(response)
|
||||
expect(fixture.client.requests.filter(request => request.method === 'runtime/respondToQuestion'))
|
||||
.toEqual([expect.objectContaining({ method: 'runtime/respondToQuestion', params: response })])
|
||||
await fixture.channel.close()
|
||||
await expect(fixture.channel.respondToQuestion(response)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('maps the complete Agent-owned prompt RPC set', async () => {
|
||||
const fixture = await openChannel()
|
||||
const identity = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { remoteQuestionResponseSchema, type RemoteQuestionResponse } from '../../shared/remote-question-contracts'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
ChannelProtocolError,
|
||||
@@ -679,6 +680,13 @@ export class ProtocolRemoteRuntimeChannel
|
||||
)
|
||||
}
|
||||
|
||||
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
|
||||
const parsed = remoteQuestionResponseSchema.parse(request)
|
||||
this.#assertOwnedPromptIdentity(parsed)
|
||||
await requestWithTimeout(this.#state.client, 'runtime/respondToQuestion', parsed,
|
||||
this.#state.controlTimeoutMs)
|
||||
}
|
||||
|
||||
async pageOwnedPromptTranscript(
|
||||
request: RemoteSemanticTranscriptPageRequest
|
||||
): Promise<RemoteSemanticTranscriptPageResult> {
|
||||
|
||||
@@ -1609,6 +1609,16 @@ const desktopApi: DesktopApi = {
|
||||
}
|
||||
},
|
||||
knowledge: {
|
||||
externalInstancesList: () => ipcRenderer.invoke(ipcChannels.externalInstancesList),
|
||||
externalInstancesSave: (input) => ipcRenderer.invoke(ipcChannels.externalInstancesSave,input),
|
||||
externalInstancesTest: (input) => ipcRenderer.invoke(ipcChannels.externalInstancesTest,input),
|
||||
externalInstancesSetEnabled: (input) => ipcRenderer.invoke(ipcChannels.externalInstancesSetEnabled,input),
|
||||
externalInstancesDelete: (input) => ipcRenderer.invoke(ipcChannels.externalInstancesDelete,input),
|
||||
externalCatalogList: (input) => ipcRenderer.invoke(ipcChannels.externalCatalogList,input),
|
||||
externalCatalogGet: (input) => ipcRenderer.invoke(ipcChannels.externalCatalogGet,input),
|
||||
externalBindingsCreate: (input) => ipcRenderer.invoke(ipcChannels.externalBindingsCreate,input),
|
||||
externalBindingsUpdate: (input) => ipcRenderer.invoke(ipcChannels.externalBindingsUpdate,input),
|
||||
externalRetrievalTest: (input) => ipcRenderer.invoke(ipcChannels.externalRetrievalTest,input),
|
||||
getSnapshot: (libraryId?: string) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.knowledgeSnapshot,
|
||||
|
||||
@@ -10,12 +10,18 @@ type AgentQuestion = Extract<AgentEvent, { type: 'question' }>
|
||||
|
||||
type AgentQuestionCardProps = {
|
||||
value: AgentQuestion
|
||||
id?: string
|
||||
taskTitle?: string
|
||||
pendingCount?: number
|
||||
onReject: () => Promise<void>
|
||||
onSubmit: (answers: AgentQuestionAnswer[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function AgentQuestionCard({
|
||||
value,
|
||||
id,
|
||||
taskTitle,
|
||||
pendingCount = 1,
|
||||
onReject,
|
||||
onSubmit
|
||||
}: AgentQuestionCardProps): React.JSX.Element {
|
||||
@@ -60,6 +66,8 @@ export function AgentQuestionCard({
|
||||
return (
|
||||
<form
|
||||
className="agent-question-card"
|
||||
id={id}
|
||||
tabIndex={-1}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (complete) {
|
||||
@@ -71,6 +79,8 @@ export function AgentQuestionCard({
|
||||
<CircleHelp aria-hidden="true" size={18} />
|
||||
<strong>{t('question.title')}</strong>
|
||||
</header>
|
||||
{taskTitle && <p>{t('question.task', { title: taskTitle })}</p>}
|
||||
{pendingCount > 1 && <p>{t('question.pendingCount', { count: pendingCount })}</p>}
|
||||
{value.questions.map((question, questionIndex) => (
|
||||
<fieldset key={`${question.header}:${questionIndex}`}>
|
||||
<legend>
|
||||
|
||||
@@ -815,6 +815,16 @@ const api: DesktopApi = {
|
||||
}),
|
||||
},
|
||||
knowledge: {
|
||||
externalInstancesList: vi.fn(async () => []),
|
||||
externalInstancesSave: vi.fn(),
|
||||
externalInstancesTest: vi.fn(),
|
||||
externalInstancesSetEnabled: vi.fn(),
|
||||
externalInstancesDelete: vi.fn(),
|
||||
externalCatalogList: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
externalCatalogGet: vi.fn(),
|
||||
externalBindingsCreate: vi.fn(),
|
||||
externalBindingsUpdate: vi.fn(),
|
||||
externalRetrievalTest: vi.fn(async () => ({ results: [], durationMs: 1 })),
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
libraries: [],
|
||||
sources: [],
|
||||
@@ -1218,6 +1228,289 @@ describe("App", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("project activity integration", () => {
|
||||
const otherProject = {
|
||||
...project,
|
||||
id: "00000000-0000-4000-8000-000000000102",
|
||||
name: "Background project",
|
||||
builtInDefault: false,
|
||||
};
|
||||
const conversations: ConversationSnapshot[] = [
|
||||
{ id: "activity-current", projectId, title: "Current discussion", updatedAt: 300, messages: [] },
|
||||
{ id: "activity-other-latest", projectId: otherProject.id, title: "Other latest", updatedAt: 200, messages: [] },
|
||||
{ id: "activity-target", projectId: otherProject.id, title: "Exact background discussion", updatedAt: 100,
|
||||
messages: [{ id: "activity-message", role: "assistant", content: "Exact conversation content", createdAt: 100, state: "complete" }] },
|
||||
];
|
||||
const task: AssistantTask = {
|
||||
id: "activity-task", projectId: otherProject.id,
|
||||
conversationId: "activity-target", title: "Background scheduled task",
|
||||
instructions: "Check background progress", origin: "schedule", status: "running",
|
||||
createdAt: "2026-08-19T00:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([project, otherProject]);
|
||||
vi.mocked(api.conversations.list).mockResolvedValue(conversations);
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task]);
|
||||
});
|
||||
|
||||
it("keeps cross-project task counts independent of initial and current search and clears on refresh", async () => {
|
||||
const initialTasks = deferred<AssistantTask[]>();
|
||||
vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise);
|
||||
render(<App />);
|
||||
const search = await screen.findByLabelText("搜索对话");
|
||||
fireEvent.change(search, { target: { value: "no matching conversation" } });
|
||||
await act(async () => initialTasks.resolve([task, { ...task, id: "duplicate-task" }]));
|
||||
const summary = await screen.findByRole("button", { name: /全项目活动/u });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
expect(screen.queryByTitle("Exact background discussion")).not.toBeInTheDocument();
|
||||
fireEvent.change(search, { target: { value: "Current discussion" } });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
const projectRow = within(screen.getByRole("menu", { name: "当前项目" }))
|
||||
.getByRole("menuitemradio", { name: /Background project/u });
|
||||
expect(projectRow).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(summary);
|
||||
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
|
||||
const running = within(dialog).getByRole("region", { name: "运行中" });
|
||||
expect(within(running).getAllByRole("button")).toHaveLength(1);
|
||||
expect(within(running).getByRole("button", { name: /Background project Exact background discussion/u })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("region", { name: "待处理" })).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: "completed" }]);
|
||||
act(() => conversationQueueChangeListener?.(task.conversationId!));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
expect(within(screen.getByRole("menu")).getByRole("menuitemradio", { name: /Background project/u }))
|
||||
.not.toHaveTextContent("个运行中");
|
||||
});
|
||||
|
||||
it("opens the exact off-project conversation and clears task selection, search, menu and narrow sidebar", async () => {
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task, {
|
||||
...task, id: "current-task", projectId, conversationId: "activity-current", title: "Current task", status: "idle",
|
||||
}]);
|
||||
const { container } = render(<App />);
|
||||
fireEvent.click(await screen.findByLabelText("展开或折叠“Current discussion”中的 1 个任务"));
|
||||
fireEvent.click(screen.getByText("Current task", { selector: ".conversation-task-child__title" }).closest("button")!);
|
||||
expect(await screen.findByRole("region", { name: "当前会话的任务" })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "Current discussion" } });
|
||||
const originalWidth = window.innerWidth;
|
||||
try {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 680 });
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
const sidebar = container.querySelector<HTMLElement>(".sidebar")!;
|
||||
await waitFor(() => expect(sidebar).toHaveClass("sidebar--closed"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
await waitFor(() => expect(sidebar.querySelector(".conversation-more")).toBeInTheDocument());
|
||||
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
|
||||
expect(sidebar.querySelector(".conversation-actions")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
|
||||
.getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
expect(sidebar).toHaveClass("sidebar--closed");
|
||||
expect(within(screen.getByRole("region", { name: "当前会话的任务" }))
|
||||
.getByRole("button", { name: /会话任务/u })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
expect(screen.getByLabelText("搜索对话")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
|
||||
expect((await within(sidebar).findByTitle("Exact background discussion")).closest("button"))
|
||||
.toHaveClass("conversation-item--active");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
selectProjectOption(project.name);
|
||||
expect(sidebar.querySelector(".conversation-actions")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps keyboard focus inside activity without dismissing the narrow sidebar", async () => {
|
||||
const { container } = render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
const originalWidth = window.innerWidth;
|
||||
try {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 680 });
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
const trigger = screen.getByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
|
||||
const close = within(dialog).getByRole("button", { name: "关闭全项目活动" });
|
||||
expect(close).toHaveFocus();
|
||||
// Native Tab must remain available between controls, even through a React portal.
|
||||
expect(fireEvent.keyDown(close, { key: "Tab" })).toBe(true);
|
||||
fireEvent.keyDown(close, { key: "Tab", shiftKey: true });
|
||||
expect(within(dialog).getByRole("button", { name: /Exact background discussion/u })).toHaveFocus();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
|
||||
expect(trigger).toHaveFocus();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects the Settings leave guard before changing project and conversation", async () => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
|
||||
.getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("Background project");
|
||||
expect(screen.getByLabelText("默认工作区目录")).toHaveValue("C:\\Unsaved activity draft");
|
||||
fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
|
||||
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["question", "approval"] as const)("preserves live %s across snapshot refresh and clears persisted terminal prompts", async (kind) => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Refresh pending prompt" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
act(() => agentListener?.(kind === "question" ? {
|
||||
requestId: request.requestId, type: "question", questionId: "refresh-question",
|
||||
questions: [{ header: "Scope", question: "Refresh question?", options: [], multiple: false, custom: true }],
|
||||
} : {
|
||||
requestId: request.requestId, type: "approval", approvalId: "refresh-approval",
|
||||
title: "Refresh approval", description: "Confirm refresh", toolName: "Bash", argumentSummary: "echo refresh", allowPermanent: false,
|
||||
}));
|
||||
if (kind === "question") act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "question", questionId: "refresh-queued",
|
||||
questions: [{ header: "Scope", question: "Queued across refresh?", options: [], multiple: false, custom: true }],
|
||||
}));
|
||||
await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ header: expect.objectContaining({ id: request.conversationId }), messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: "assistant", state: "streaming", status: undefined }),
|
||||
]) }),
|
||||
])));
|
||||
const write = vi.mocked(api.conversations.saveLocal).mock.calls.at(-1)![0]
|
||||
.find((conversation) => conversation.header.id === request.conversationId)!;
|
||||
const saved = { ...write.header, messages: write.messages };
|
||||
for (const updatedAt of [saved.updatedAt, saved.updatedAt + 60_000]) {
|
||||
const refreshed = { ...saved, updatedAt, title: `Snapshot ${updatedAt}` };
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([refreshed]);
|
||||
act(() => conversationQueueChangeListener?.(request.conversationId!));
|
||||
await screen.findAllByText(refreshed.title);
|
||||
if (kind === "question") expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
}
|
||||
let resolveReply: (() => void) | undefined;
|
||||
if (kind === "question") {
|
||||
vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise<void>((resolve) => { resolveReply = resolve; }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
|
||||
await waitFor(() => expect(resolveReply).toBeDefined());
|
||||
}
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([{
|
||||
...saved, updatedAt: saved.updatedAt + 120_000,
|
||||
messages: saved.messages.map((message) => message.role === "assistant"
|
||||
? { ...message, state: "complete", content: "Persisted terminal result" } : message),
|
||||
}]);
|
||||
act(() => conversationQueueChangeListener?.(request.conversationId!));
|
||||
await screen.findByText("Persisted terminal result");
|
||||
await act(async () => resolveReply?.());
|
||||
expect(screen.getByText("Persisted terminal result")).toBeInTheDocument();
|
||||
if (kind === "question") expect(screen.getByText("问题与回答")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it.each((["question", "approval"] as const).flatMap((kind) =>
|
||||
(["success", "question", "approval", "done", "failure"] as const).map((outcome) => ({ kind, outcome })),
|
||||
))("settles $kind response activity safely on $outcome", async ({ kind, outcome }) => {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
vi.mocked(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval)
|
||||
.mockImplementationOnce(() => new Promise<void>((yes, no) => { resolve = yes; reject = no; }));
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Response activity" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
const questionEvent = {
|
||||
requestId: request.requestId, type: "question" as const, questionId: "first-question",
|
||||
questions: [{ header: "Scope", question: "Response question?", options: [], multiple: false, custom: true }],
|
||||
};
|
||||
const approvalEvent = {
|
||||
requestId: request.requestId, type: "approval" as const, approvalId: "first-approval",
|
||||
title: "Response approval", description: "Confirm response", toolName: "Bash", argumentSummary: "echo response", allowPermanent: false,
|
||||
};
|
||||
act(() => agentListener?.(kind === "question" ? questionEvent : approvalEvent));
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" }));
|
||||
await waitFor(() => expect(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
if (outcome === "failure") reject(new Error("Response failed"));
|
||||
else {
|
||||
if (outcome === "question") agentListener?.({ ...questionEvent, questionId: "next-question", questions: [{ ...questionEvent.questions[0]!, question: "Next question?" }] });
|
||||
if (outcome === "approval") agentListener?.({ ...approvalEvent, approvalId: "next-approval", title: "Next approval" });
|
||||
if (outcome === "done") agentListener?.({ requestId: request.requestId, type: "done" });
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const summary = screen.getByRole("button", { name: /全项目活动/u });
|
||||
expect(summary).toHaveTextContent(outcome === "success" ? "2 个运行中"
|
||||
: outcome === "done" ? "1 个运行中" : "1 个待处理 1 个运行中");
|
||||
if (outcome === "success" || outcome === "done") expect(summary).not.toHaveTextContent("待处理");
|
||||
if (outcome === "question") expect(screen.getByText("Next question?")).toBeInTheDocument();
|
||||
if (outcome === "approval") expect(screen.getAllByText("Next approval").length).toBeGreaterThan(0);
|
||||
if (outcome === "failure") expect(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it.each(["question", "approval"] as const)("groups pending %s ahead of running, deduplicates live and task activity, and clears terminal events", async (kind) => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Track live activity" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("2 个运行中");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task, {
|
||||
...task, id: "live-task", projectId, conversationId: request.conversationId, status: "waiting_approval",
|
||||
}]);
|
||||
act(() => {
|
||||
agentListener?.(kind === "question" ? {
|
||||
requestId: request.requestId, type: "question", questionId: "activity-question",
|
||||
questions: [{ header: "范围", question: "继续吗?", options: [], multiple: false, custom: true }],
|
||||
} : {
|
||||
requestId: request.requestId, type: "approval", approvalId: "activity-approval",
|
||||
title: "Approve activity", description: "Confirm execution", toolName: "Bash", argumentSummary: "echo activity", allowPermanent: true,
|
||||
});
|
||||
conversationQueueChangeListener?.(request.conversationId!);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
|
||||
expect(within(dialog).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(["待处理", "运行中"]);
|
||||
const pending = within(dialog).getByRole("region", { name: "待处理" });
|
||||
expect(within(pending).getAllByRole("button")).toHaveLength(1);
|
||||
expect(pending).toHaveTextContent(kind === "question" ? "等待你的回答" : "等待审批");
|
||||
expect(within(dialog).getByRole("region", { name: "运行中" })).toHaveTextContent("Exact background discussion");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
act(() => {
|
||||
agentListener?.(kind === "question"
|
||||
? { requestId: request.requestId, type: "done" }
|
||||
: { requestId: request.requestId, type: "error", status: "failed", message: "Activity execution failed" });
|
||||
conversationQueueChangeListener?.(request.conversationId!);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("provides custom minimize, maximize, and close controls", async () => {
|
||||
const { unmount } = render(<App />);
|
||||
|
||||
@@ -1968,9 +2261,14 @@ describe("App", () => {
|
||||
expect(await screen.findAllByText("请求写入工作区")).not.toHaveLength(0);
|
||||
expect(screen.getByText("仅此次")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(screen.getByRole("button", { name: "仅此次" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中"));
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("idle-preloads only the small Heartbeat route at startup", async () => {
|
||||
const previousLoads = { ...routeModuleLoads };
|
||||
render(<App />);
|
||||
|
||||
expect(window.requestIdleCallback).toHaveBeenCalledWith(
|
||||
@@ -1987,10 +2285,10 @@ describe("App", () => {
|
||||
});
|
||||
await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1));
|
||||
expect(routeModuleLoads).toMatchObject({
|
||||
activity: 0,
|
||||
knowledge: 0,
|
||||
magicNotes: 0,
|
||||
settings: 0,
|
||||
activity: previousLoads.activity,
|
||||
knowledge: previousLoads.knowledge,
|
||||
magicNotes: previousLoads.magicNotes,
|
||||
settings: previousLoads.settings,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4709,6 +5007,43 @@ describe("App", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves distinct external chunks with identical snippets across reference events", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "Find evidence" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
const references = ["chunk-a", "chunk-b"].map((remoteChunkId) => ({
|
||||
libraryId: "11111111-1111-4111-8111-111111111111",
|
||||
libraryName: "External library",
|
||||
documentName: "Shared title",
|
||||
sourceName: "Shared source",
|
||||
snippet: "Identical evidence",
|
||||
rank: 1,
|
||||
external: {
|
||||
kind: "external" as const,
|
||||
provider: "dify" as const,
|
||||
instanceId: "instance-a",
|
||||
remoteKnowledgeBaseId: "dataset-a",
|
||||
remoteDocumentId: "document-a",
|
||||
remoteChunkId,
|
||||
},
|
||||
}));
|
||||
act(() => {
|
||||
for (const reference of [...references, references[0]!]) {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: "source-references",
|
||||
references: [reference],
|
||||
});
|
||||
}
|
||||
agentListener?.({ requestId: request.requestId, type: "done" });
|
||||
});
|
||||
expect(await screen.findByText("查看 2 条证据引用")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits knowledge scope without eager search or prompt injection and merges runtime references", async () => {
|
||||
const libraryId = "11111111-1111-4111-8111-111111111111";
|
||||
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
|
||||
@@ -5998,6 +6333,41 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a new external binding to the current selection without re-enabling an excluded library", async () => {
|
||||
const existing = { id: '11111111-1111-4111-8111-111111111111', name: 'Existing', description: '', storageMode: 'managed' as const, graphEnabled: false, graphStrategy: 'rules' as const, sourceCount: 0, documentCount: 0, indexedDocumentCount: 0 };
|
||||
const excluded = { ...existing, id: '22222222-2222-4222-8222-222222222222', name: 'Excluded' };
|
||||
const instance = { id: 'instance-1', name: 'Company Dify', provider: 'dify' as const, baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured' as const, probeStatus: 'healthy' as const, bindingCount: 0 };
|
||||
const created = { ...existing, id: '33333333-3333-4333-8333-333333333333', name: 'Remote handbook', external: { knowledgeBaseId: '33333333-3333-4333-8333-333333333333', instanceId: instance.id, provider: 'dify' as const, remoteKnowledgeBaseId: 'remote-1', remoteName: 'Remote handbook', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: { provider: 'dify' as const, useDatasetDefaults: true as const }, lastVerifiedAt: '2026-09-12' } };
|
||||
const initial = { libraries: [existing, excluded], selectedLibraryId: existing.id, sources: [], documents: [], graphNodes: [], graphRelations: [], evidence: [] };
|
||||
const updated = { ...initial, libraries: [...initial.libraries, created], selectedLibraryId: created.id };
|
||||
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce(initial).mockResolvedValueOnce(updated);
|
||||
vi.mocked(api.knowledge.externalInstancesList).mockResolvedValueOnce([instance]).mockResolvedValueOnce([instance]);
|
||||
vi.mocked(api.knowledge.externalCatalogList).mockResolvedValueOnce({ items: [{ id: 'remote-1', name: 'Remote handbook' }], hasMore: false });
|
||||
vi.mocked(api.knowledge.externalCatalogGet).mockResolvedValueOnce({ id: 'remote-1', name: 'Remote handbook' });
|
||||
vi.mocked(api.knowledge.externalBindingsCreate).mockResolvedValueOnce(updated);
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: '44444444-4444-4444-8444-444444444444', projectId, title: 'External scope test', updatedAt: 200, messages: [], knowledgeLibraryIds: [existing.id] }]);
|
||||
render(<App />);
|
||||
await screen.findByRole('button', { name: /^External scope test/u });
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '新建知识库' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dify' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '外部实例' }));
|
||||
fireEvent.click(within(screen.getByRole('menu', { name: '外部实例' })).getByRole('menuitemradio', { name: /Company Dify/u }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Remote handbook/u }));
|
||||
fireEvent.change(screen.getByLabelText('测试查询'), { target: { value: 'network policy' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试检索' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '添加知识库' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加知识库' }));
|
||||
await screen.findByRole('heading', { name: 'Remote handbook' });
|
||||
expect(api.knowledge.getSnapshot).toHaveBeenLastCalledWith(created.id);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^External scope test/u }));
|
||||
openComposerOptions();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '选择知识库,本次已启用 2 个' }));
|
||||
expect(screen.getByRole('checkbox', { name: /Existing/u })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /Excluded/u })).not.toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /Remote handbook.*Dify.*Company Dify/u })).toBeChecked();
|
||||
});
|
||||
|
||||
it("ignores stale Git changes after switching projects", async () => {
|
||||
const secondProject = {
|
||||
...project,
|
||||
@@ -8049,6 +8419,8 @@ describe("App", () => {
|
||||
.mockResolvedValueOnce(failed);
|
||||
const callsBefore = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length;
|
||||
selectProjectOption(firstProject.name);
|
||||
// Let the project picker restore focus before opening the dismiss-on-blur options.
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
||||
await act(async () => {
|
||||
@@ -8067,6 +8439,9 @@ describe("App", () => {
|
||||
await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 3));
|
||||
vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty);
|
||||
selectProjectOption(firstProject.name);
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "true");
|
||||
await waitFor(() => expect(screen.queryByRole("group", { name: "OpenCode 专属功能" })).not.toBeInTheDocument());
|
||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 4);
|
||||
});
|
||||
@@ -10267,6 +10642,89 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("queues concurrent task questions by ID without losing drafts on duplicates or failed replies", async () => {
|
||||
let reject!: (error: Error) => void;
|
||||
vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise<void>((_resolve, no) => { reject = no; }));
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Parallel questions" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
const first: Extract<AgentEvent, { type: "question" }> = {
|
||||
requestId, type: "question", questionId: "queue-first", childTaskId: "queue-child",
|
||||
questions: [{ header: "Scope", question: "First queued question?", options: [], multiple: false, custom: true }],
|
||||
};
|
||||
const second = { ...first, questionId: "queue-second", childTaskId: undefined,
|
||||
questions: [{ ...first.questions[0]!, question: "Second queued question?" }] };
|
||||
act(() => {
|
||||
agentListener?.({ requestId, type: "subagent", childTaskId: "queue-child", expertId: "expert",
|
||||
expertName: "general", routingMode: "native", state: "running", reason: "Inspect queue task" });
|
||||
agentListener?.(first);
|
||||
agentListener?.(first);
|
||||
});
|
||||
expect(screen.queryByText("还有 2 个待答问题,请依次回答。")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), { target: { value: "Keep this draft" } });
|
||||
act(() => { agentListener?.(second); agentListener?.(first); });
|
||||
expect(screen.getByText("任务:Inspect queue task")).toBeInTheDocument();
|
||||
expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Second queued question?")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Keep this draft");
|
||||
const taskCard = screen.getByText("Inspect queue task").closest("details")!;
|
||||
expect(taskCard).toHaveTextContent("等待你的回答");
|
||||
taskCard.open = true;
|
||||
fireEvent(taskCard, new Event("toggle"));
|
||||
const form = document.querySelector<HTMLFormElement>(".agent-question-card")!;
|
||||
form.scrollIntoView = vi.fn();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "前往问答表单" }));
|
||||
expect(document.activeElement).toHaveClass("agent-question-card");
|
||||
expect(form.scrollIntoView).toHaveBeenCalledWith({ block: "center" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
const third = { ...second, questionId: "queue-third",
|
||||
questions: [{ ...first.questions[0]!, question: "Third queued question?" }] };
|
||||
await act(async () => { agentListener?.(third); reject(new Error("Queue reply failed")); });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Queue reply failed");
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Keep this draft");
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
await screen.findByText("Second queued question?");
|
||||
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-first", [["Keep this draft"]]);
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("");
|
||||
act(() => agentListener?.(first));
|
||||
expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
|
||||
await screen.findByText("Third queued question?");
|
||||
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-second", undefined);
|
||||
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument());
|
||||
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-third", undefined);
|
||||
expect(screen.getAllByText("问题与回答")).toHaveLength(3);
|
||||
act(() => agentListener?.(first));
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("removes remotely resolved questions without recording a skip or blocking the next question", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Remote questions" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
act(() => {
|
||||
for (const id of ["first", "second"]) agentListener?.({ requestId, type: "question", questionId: id,
|
||||
questions: [{ header: "Input", question: `Remote ${id}?`, options: [], multiple: false, custom: true }] });
|
||||
});
|
||||
expect(screen.getByText("Remote first?")).toBeInTheDocument();
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "first" }));
|
||||
expect(screen.getByText("Remote second?")).toBeInTheDocument();
|
||||
expect(screen.queryByText("问题与回答")).not.toBeInTheDocument();
|
||||
expect(api.agent.respondQuestion).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), { target: { value: "Retain draft" } });
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "first" }));
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Retain draft");
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "second" }));
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("renders, saves and reloads OpenCode answers across question rounds", async () => {
|
||||
const view = render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
@@ -10363,7 +10821,7 @@ describe("App", () => {
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["done", "question", "failure"] as const)(
|
||||
it.each(["done", "cancelled", "error", "question", "resolved", "failure"] as const)(
|
||||
"preserves question state when %s occurs during answer submission",
|
||||
async (outcome) => {
|
||||
let resolve!: () => void;
|
||||
@@ -10386,6 +10844,10 @@ describe("App", () => {
|
||||
act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "question", questionId: "first", questions,
|
||||
}));
|
||||
act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "question", questionId: "queued",
|
||||
questions: [{ ...questions[0]!, question: "Already queued?" }],
|
||||
}));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), {
|
||||
target: { value: "保留完整自定义回答\n" + "检查".repeat(600) },
|
||||
});
|
||||
@@ -10395,8 +10857,12 @@ describe("App", () => {
|
||||
if (outcome === "failure") {
|
||||
reject(new Error("Answer delivery failed"));
|
||||
} else {
|
||||
agentListener?.(outcome === "done"
|
||||
? { requestId: request.requestId, type: "done" }
|
||||
agentListener?.(outcome === "cancelled" || outcome === "error"
|
||||
? { requestId: request.requestId, type: "error", status: outcome === "cancelled" ? "cancelled" : "failed", message: "Terminal queue result" }
|
||||
: outcome === "done"
|
||||
? { requestId: request.requestId, type: "done" }
|
||||
: outcome === "resolved"
|
||||
? { requestId: request.requestId, type: "question-resolved", questionId: "first" }
|
||||
: { requestId: request.requestId, type: "question", questionId: "second",
|
||||
questions: [{ ...questions[0]!, question: "是否继续下一步?" }] });
|
||||
resolve();
|
||||
@@ -10416,9 +10882,18 @@ describe("App", () => {
|
||||
})]),
|
||||
})]),
|
||||
));
|
||||
if (outcome === "question") {
|
||||
expect(screen.getByText("是否继续下一步?")).toBeInTheDocument();
|
||||
if (outcome === "question" || outcome === "resolved") {
|
||||
expect(screen.getByText("Already queued?")).toBeInTheDocument();
|
||||
expect(screen.queryByText("是否继续下一步?")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "提交回答" })).toBeInTheDocument();
|
||||
if (outcome === "question") {
|
||||
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
|
||||
expect(await screen.findByText("是否继续下一步?")).toBeInTheDocument();
|
||||
}
|
||||
} else {
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Already queued?")).not.toBeInTheDocument();
|
||||
if (outcome !== "done") expect(screen.getByRole("alert")).toHaveTextContent("Terminal queue result");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+222
-93
@@ -56,6 +56,7 @@ import {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { knowledgeReferenceKey } from "../../shared/knowledge-reference";
|
||||
import type { TFunction } from "i18next";
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
@@ -161,6 +162,8 @@ import {
|
||||
ScopeBadge,
|
||||
} from "./WorkspacePrimitives";
|
||||
import { ProjectSwitcher } from "./ProjectSwitcher";
|
||||
import { ProjectActivity } from "./ProjectActivity";
|
||||
import { deriveConversationActivity } from "./conversation-activity";
|
||||
import {
|
||||
RightAssistantSidebar,
|
||||
type AssistantSidebarTab,
|
||||
@@ -1461,10 +1464,14 @@ function mergePersistedConversations(
|
||||
);
|
||||
const messages = [
|
||||
...conversation.messages.map((message) => {
|
||||
const localMessage = localMessageById.get(message.id);
|
||||
if (!localIsNewer) {
|
||||
// Pending prompts are live-only and are omitted from persisted messages.
|
||||
if (localMessage?.state === "streaming" && message.state === "streaming") {
|
||||
return { ...message, approval: localMessage.approval, pendingQuestions: localMessage.pendingQuestions };
|
||||
}
|
||||
return message;
|
||||
}
|
||||
const localMessage = localMessageById.get(message.id);
|
||||
if (
|
||||
!localMessage ||
|
||||
persistedTerminalStateOverridesLocal(localMessage, message)
|
||||
@@ -2547,11 +2554,13 @@ function App(): React.JSX.Element {
|
||||
tasks: [],
|
||||
},
|
||||
);
|
||||
const [externalInstances, setExternalInstances] = useState<import('../../shared/external-knowledge-contracts').ExternalKnowledgeInstanceSummary[]>([]);
|
||||
const [knowledgeLoading, setKnowledgeLoading] = useState(true);
|
||||
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>();
|
||||
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0);
|
||||
const knowledgeOperationCountRef = useRef(knowledgeOperationCount);
|
||||
const knowledgeLoadRequestRef = useRef(0);
|
||||
const citationRequestRef = useRef(0);
|
||||
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(undefined);
|
||||
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -2855,6 +2864,7 @@ function App(): React.JSX.Element {
|
||||
?.focus();
|
||||
});
|
||||
const closeOnEscape = (event: KeyboardEvent): void => {
|
||||
if (sidebarRef.current?.closest<HTMLElement>(".app-shell")?.inert) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeNarrowSidebar();
|
||||
@@ -3763,6 +3773,26 @@ function App(): React.JSX.Element {
|
||||
),
|
||||
[projects, tWorkspace],
|
||||
);
|
||||
const projectActivity = useMemo(
|
||||
() => deriveConversationActivity(
|
||||
conversations.map((conversation) => ({
|
||||
...conversation,
|
||||
title: conversationTitles.get(conversation.id) ?? conversation.title,
|
||||
})),
|
||||
assistantTasks,
|
||||
activeConversationIds,
|
||||
projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: projectNames.get(project.id) ?? project.name,
|
||||
})),
|
||||
tWorkspace("projectActivity.unassigned"),
|
||||
),
|
||||
[conversations, conversationTitles, assistantTasks, activeConversationIds,
|
||||
projects, projectNames, tWorkspace],
|
||||
);
|
||||
const activityByConversationId = new Map(
|
||||
projectActivity.activities.map((activity) => [activity.conversationId, activity]),
|
||||
);
|
||||
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
|
||||
() =>
|
||||
conversations.flatMap((conversation) =>
|
||||
@@ -3939,13 +3969,16 @@ function App(): React.JSX.Element {
|
||||
async (libraryId?: string): Promise<KnowledgeSnapshot> => {
|
||||
const requestId = ++knowledgeLoadRequestRef.current;
|
||||
try {
|
||||
const snapshot =
|
||||
await window.goodbuddy.knowledge.getSnapshot(libraryId);
|
||||
const [snapshot, instances] = await Promise.all([
|
||||
window.goodbuddy.knowledge.getSnapshot(libraryId),
|
||||
window.goodbuddy.knowledge.externalInstancesList(),
|
||||
]);
|
||||
if (requestId !== knowledgeLoadRequestRef.current) {
|
||||
return snapshot;
|
||||
}
|
||||
failedKnowledgeLibraryIdRef.current = undefined;
|
||||
setKnowledgeSnapshot(snapshot);
|
||||
setExternalInstances(instances);
|
||||
setKnowledgeLoadError(undefined);
|
||||
const availableIds = new Set(
|
||||
snapshot.libraries.map((library) => library.id),
|
||||
@@ -4232,6 +4265,27 @@ function App(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveMessage = conversationsRef.current
|
||||
.find((conversation) => conversation.id === run.conversationId)
|
||||
?.messages.find((message) => message.id === run.messageId);
|
||||
if (event.type === "question-resolved") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
if (!message.pendingQuestions?.some((question) => question.questionId === event.questionId)) return message;
|
||||
const pendingQuestions = message.pendingQuestions.filter((question) => question.questionId !== event.questionId);
|
||||
if (!pendingQuestions.length && !message.approval && message.state === "streaming") {
|
||||
setAssistantTasks((current) => current.map((task) =>
|
||||
task.id === event.requestId && task.status === "waiting_approval" ? { ...task, status: "running" } : task,
|
||||
));
|
||||
}
|
||||
return { ...message, pendingQuestions };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === "question" && (
|
||||
liveMessage?.state !== "streaming" ||
|
||||
liveMessage.pendingQuestions?.some((question) => question.questionId === event.questionId) ||
|
||||
liveMessage.answeredQuestions?.some((question) => question.questionId === event.questionId)
|
||||
)) return;
|
||||
setAssistantTasks((current) => {
|
||||
let changed = false;
|
||||
const updated = current.map((task) => {
|
||||
@@ -4245,7 +4299,9 @@ function App(): React.JSX.Element {
|
||||
? "completed"
|
||||
: event.type === "error"
|
||||
? event.status
|
||||
: "running";
|
||||
: liveMessage?.pendingQuestions?.length || liveMessage?.approval
|
||||
? "waiting_approval"
|
||||
: "running";
|
||||
const completedAt =
|
||||
event.type === "done" || event.type === "error"
|
||||
? new Date().toISOString()
|
||||
@@ -4640,11 +4696,18 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
}));
|
||||
} else if (event.type === "question") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
status: undefined,
|
||||
question: event,
|
||||
}));
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
if (message.state !== "streaming" ||
|
||||
message.pendingQuestions?.some((question) => question.questionId === event.questionId) ||
|
||||
message.answeredQuestions?.some((question) => question.questionId === event.questionId)) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
status: undefined,
|
||||
pendingQuestions: [...(message.pendingQuestions ?? []), event],
|
||||
};
|
||||
});
|
||||
} else if (event.type === "artifact") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
@@ -4673,27 +4736,19 @@ function App(): React.JSX.Element {
|
||||
}));
|
||||
} else if (event.type === "source-references") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const referenceKey = (reference: KnowledgeSearchReference): string =>
|
||||
[
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? "",
|
||||
reference.locator ?? "",
|
||||
reference.snippet,
|
||||
].join("\0");
|
||||
const incoming = [
|
||||
...new Map(
|
||||
event.references.map((reference) => [
|
||||
referenceKey(reference),
|
||||
knowledgeReferenceKey(reference),
|
||||
reference,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const incomingKeys = new Set(incoming.map(referenceKey));
|
||||
const incomingKeys = new Set(incoming.map(knowledgeReferenceKey));
|
||||
const references = [
|
||||
...incoming,
|
||||
...(message.sourceReferences ?? []).filter(
|
||||
(reference) => !incomingKeys.has(referenceKey(reference)),
|
||||
(reference) => !incomingKeys.has(knowledgeReferenceKey(reference)),
|
||||
),
|
||||
].slice(0, 20);
|
||||
return {
|
||||
@@ -4812,7 +4867,7 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
: message.contextCompressions,
|
||||
approval: undefined,
|
||||
question: undefined,
|
||||
pendingQuestions: undefined,
|
||||
tools: toolTerminalState
|
||||
? message.tools?.map((tool) =>
|
||||
tool.state === "pending" || tool.state === "running"
|
||||
@@ -6715,11 +6770,16 @@ function App(): React.JSX.Element {
|
||||
|
||||
const openCitationContext = useCallback(
|
||||
async (reference: KnowledgeSearchReference): Promise<void> => {
|
||||
const request = ++citationRequestRef.current;
|
||||
if (reference.external) {
|
||||
setCitationDialog({ reference, loading: false });
|
||||
return;
|
||||
}
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: true,
|
||||
});
|
||||
if (!reference.chunkId) {
|
||||
if (!reference.chunkId || !reference.documentId) {
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
@@ -6733,6 +6793,7 @@ function App(): React.JSX.Element {
|
||||
documentId: reference.documentId,
|
||||
chunkId: reference.chunkId,
|
||||
});
|
||||
if (request !== citationRequestRef.current) return;
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
@@ -6747,6 +6808,7 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
});
|
||||
} catch (reason) {
|
||||
if (request !== citationRequestRef.current) return;
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
@@ -6762,7 +6824,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
const openCitationSource = useCallback(
|
||||
async (reference: KnowledgeSearchReference): Promise<void> => {
|
||||
if (!reference.chunkId) {
|
||||
if (reference.external || !reference.chunkId || !reference.documentId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -7123,7 +7185,9 @@ function App(): React.JSX.Element {
|
||||
startedAt,
|
||||
},
|
||||
...current,
|
||||
].slice(0, 100),
|
||||
].filter((task, index) =>
|
||||
index < 100 || task.status === "running" || task.status === "waiting_approval",
|
||||
),
|
||||
);
|
||||
recordActivity({
|
||||
conversationId,
|
||||
@@ -7349,6 +7413,12 @@ function App(): React.JSX.Element {
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision,
|
||||
): Promise<void> => {
|
||||
const pendingMessage = conversationsRef.current
|
||||
.find((conversation) => conversation.id === conversationId)
|
||||
?.messages.find((message) => message.id === messageId);
|
||||
if (pendingMessage?.approval?.id !== approvalId) return;
|
||||
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
|
||||
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
|
||||
try {
|
||||
await window.goodbuddy.agent.respondApproval(approvalId, decision);
|
||||
const approved = decision !== "deny";
|
||||
@@ -7380,20 +7450,29 @@ function App(): React.JSX.Element {
|
||||
return record;
|
||||
});
|
||||
});
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
...message,
|
||||
approval: undefined,
|
||||
status:
|
||||
approved && message.task
|
||||
? undefined
|
||||
: approved
|
||||
? tRef.current("chat.approval.executing", {
|
||||
decision: decisionLabel,
|
||||
})
|
||||
: tRef.current("chat.approval.denied"),
|
||||
}));
|
||||
updateMessage(conversationId, messageId, (message) => {
|
||||
if (message.approval?.id !== approvalId) return message;
|
||||
if (!message.pendingQuestions?.length) {
|
||||
setAssistantTasks((current) => current.map((task) =>
|
||||
task.id === taskId && task.status === "waiting_approval"
|
||||
? { ...task, status: "running" }
|
||||
: task,
|
||||
));
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
approval: undefined,
|
||||
status: message.pendingQuestions?.length
|
||||
? message.status
|
||||
: approved && message.task
|
||||
? undefined
|
||||
: approved
|
||||
? tRef.current("chat.approval.executing", { decision: decisionLabel })
|
||||
: tRef.current("chat.approval.denied"),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
updateMessage(conversationId, messageId, (message) => message.approval?.id !== approvalId ? message : ({
|
||||
...message,
|
||||
status: tRef.current("chat.approval.responseFailed"),
|
||||
}));
|
||||
@@ -7409,35 +7488,48 @@ function App(): React.JSX.Element {
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[],
|
||||
): Promise<void> => {
|
||||
const question = conversationsRef.current
|
||||
const pendingMessage = conversationsRef.current
|
||||
.find((conversation) => conversation.id === conversationId)
|
||||
?.messages.find((message) => message.id === messageId)?.question;
|
||||
?.messages.find((message) => message.id === messageId);
|
||||
const question = pendingMessage?.pendingQuestions?.[0];
|
||||
if (!question || question.questionId !== questionId) {
|
||||
return;
|
||||
}
|
||||
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
|
||||
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
|
||||
await window.goodbuddy.agent.respondQuestion(questionId, answers);
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
...message,
|
||||
answeredQuestions: [
|
||||
...(message.answeredQuestions ?? []),
|
||||
{
|
||||
questionId,
|
||||
skipped: answers === undefined,
|
||||
questions: question.questions.map((item, index) => ({
|
||||
...item,
|
||||
answer: answers?.[index],
|
||||
})),
|
||||
},
|
||||
],
|
||||
question: message.question?.questionId === questionId
|
||||
? undefined
|
||||
: message.question,
|
||||
status: message.question?.questionId === questionId && message.state === "streaming"
|
||||
? answers
|
||||
? tRef.current("chat.status.answerSubmitted")
|
||||
: tRef.current("chat.status.questionSkipped")
|
||||
: message.status,
|
||||
}));
|
||||
updateMessage(conversationId, messageId, (message) => {
|
||||
const pendingQuestions = message.pendingQuestions?.filter((item) => item.questionId !== questionId);
|
||||
const resumed = message.pendingQuestions?.some((item) => item.questionId === questionId) &&
|
||||
!pendingQuestions?.length && !message.approval && message.state === "streaming";
|
||||
if (resumed) {
|
||||
setAssistantTasks((current) => current.map((task) =>
|
||||
task.id === taskId && task.status === "waiting_approval"
|
||||
? { ...task, status: "running" }
|
||||
: task,
|
||||
));
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
answeredQuestions: [
|
||||
...(message.answeredQuestions ?? []).filter((item) => item.questionId !== questionId),
|
||||
{
|
||||
questionId,
|
||||
skipped: answers === undefined,
|
||||
questions: question.questions.map((item, index) => ({
|
||||
...item,
|
||||
answer: answers?.[index],
|
||||
})),
|
||||
},
|
||||
],
|
||||
pendingQuestions,
|
||||
status: resumed
|
||||
? answers
|
||||
? tRef.current("chat.status.answerSubmitted")
|
||||
: tRef.current("chat.status.questionSkipped")
|
||||
: message.status,
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateMessage],
|
||||
);
|
||||
@@ -7752,29 +7844,46 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
|
||||
const openActivityConversation = (conversationId: string): void => {
|
||||
const conversation = conversations.find(
|
||||
(candidate) => candidate.id === conversationId,
|
||||
);
|
||||
if (!conversation) {
|
||||
notify({
|
||||
tone: "info",
|
||||
message: t("notices.conversationDeleted"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (conversation.projectId) {
|
||||
setActiveProjectId(conversation.projectId);
|
||||
}
|
||||
setActiveId(conversationId);
|
||||
setUnreadConversationIds((current) => {
|
||||
if (!current.has(conversationId)) {
|
||||
return current;
|
||||
const open = async (): Promise<void> => {
|
||||
let conversation = conversationsRef.current.find(
|
||||
(candidate) => candidate.id === conversationId,
|
||||
);
|
||||
if (!conversation) {
|
||||
try {
|
||||
const persisted = await window.goodbuddy.conversations.list();
|
||||
conversation = persisted.find((candidate) => candidate.id === conversationId);
|
||||
setConversations((current) => mergePersistedConversations(
|
||||
current, persisted, persistedLocalConversationsRef.current,
|
||||
));
|
||||
} catch {
|
||||
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const next = new Set(current);
|
||||
next.delete(conversationId);
|
||||
return next;
|
||||
});
|
||||
setView("chat");
|
||||
if (!conversation) {
|
||||
notify({ tone: "info", message: t("notices.conversationDeleted") });
|
||||
return;
|
||||
}
|
||||
setActiveProjectId(conversation.projectId ?? "");
|
||||
setConversationActionsId("");
|
||||
setSelectedAssistantTaskId(undefined);
|
||||
setSearchQuery("");
|
||||
setActiveId(conversationId);
|
||||
setUnreadConversationIds((current) => {
|
||||
if (!current.has(conversationId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(conversationId);
|
||||
return next;
|
||||
});
|
||||
commitView("chat");
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
};
|
||||
const requestLeave = settingsLeaveRequesterRef.current;
|
||||
if (viewRef.current === "settings" && requestLeave) {
|
||||
requestLeave(() => { void open(); });
|
||||
} else {
|
||||
void open();
|
||||
}
|
||||
};
|
||||
|
||||
const openAssistantTask = (task: AssistantTask): void => {
|
||||
@@ -8192,7 +8301,7 @@ function App(): React.JSX.Element {
|
||||
id="primary-sidebar"
|
||||
inert={!sidebarOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (mainSidebarOpen) {
|
||||
if (mainSidebarOpen && event.currentTarget.contains(event.target as Node)) {
|
||||
trapTabFocus(event, sidebarRef.current);
|
||||
}
|
||||
}}
|
||||
@@ -8220,6 +8329,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
<ProjectSwitcher
|
||||
activeProjectId={activeProjectId}
|
||||
activityByProjectId={projectActivity.byProjectId}
|
||||
recoveryByProjectId={projectRecoveryByProjectId}
|
||||
runtimeSettings={runtimeSettings}
|
||||
onArchive={archiveProject}
|
||||
@@ -8233,6 +8343,10 @@ function App(): React.JSX.Element {
|
||||
projects={projects}
|
||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
/>
|
||||
<ProjectActivity
|
||||
activities={projectActivity.activities}
|
||||
onOpenConversation={openActivityConversation}
|
||||
/>
|
||||
|
||||
{activeProject?.kind !== "channel" && (
|
||||
<button className="new-chat" onClick={newConversation} type="button">
|
||||
@@ -8383,6 +8497,7 @@ function App(): React.JSX.Element {
|
||||
conversation,
|
||||
t("conversation.defaultTitle"),
|
||||
);
|
||||
const activity = activityByConversationId.get(conversation.id);
|
||||
const branchSourceTitle = conversation.branch
|
||||
? (conversationTitles.get(
|
||||
conversation.branch.sourceConversationId,
|
||||
@@ -8497,12 +8612,16 @@ function App(): React.JSX.Element {
|
||||
</time>
|
||||
</small>
|
||||
</button>
|
||||
{activeConversationIds.has(conversation.id) && (
|
||||
{activity && (
|
||||
<span
|
||||
aria-label={t("conversation.active")}
|
||||
className="conversation-activity-indicator"
|
||||
aria-label={activity.status === "running"
|
||||
? t("conversation.active")
|
||||
: tWorkspace(`projectActivity.status.${activity.status}`)}
|
||||
className={activity.status === "running"
|
||||
? "conversation-activity-indicator"
|
||||
: "conversation-activity-indicator conversation-activity-indicator--attention"}
|
||||
role="status"
|
||||
title={t("conversation.active")}
|
||||
title={tWorkspace(`projectActivity.status.${activity.status}`)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
@@ -9527,9 +9646,13 @@ function App(): React.JSX.Element {
|
||||
{t("composer.knowledge.scope")}
|
||||
</strong>
|
||||
{knowledgeSnapshot.libraries.map(
|
||||
(library) => (
|
||||
(library) => {
|
||||
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
|
||||
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
|
||||
return (
|
||||
<label key={library.id}>
|
||||
<input
|
||||
disabled={!!library.external && externalStatus !== 'ready'}
|
||||
checked={enabledKnowledgeLibraryIds.includes(
|
||||
library.id,
|
||||
)}
|
||||
@@ -9553,7 +9676,7 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
<span>{library.name}</span>
|
||||
<small>
|
||||
{t(
|
||||
{library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
|
||||
"composer.knowledge.documents",
|
||||
{
|
||||
count: library.documentCount,
|
||||
@@ -9561,7 +9684,7 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
</small>
|
||||
</label>
|
||||
),
|
||||
)},
|
||||
)}
|
||||
<div className="knowledge-scope__retrieval-mode">
|
||||
<strong>
|
||||
@@ -10269,6 +10392,12 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
>
|
||||
<KnowledgeWorkspace
|
||||
externalInstances={externalInstances}
|
||||
notify={notify}
|
||||
onExternalChanged={async (snapshot, createdId) => {
|
||||
await refreshKnowledge(createdId ?? snapshot?.selectedLibraryId ?? knowledgeSnapshot.selectedLibraryId);
|
||||
if (createdId) setEnabledKnowledgeLibraryIds(current => [...new Set([...current, createdId])]);
|
||||
}}
|
||||
documents={knowledgeSnapshot.documents}
|
||||
evidence={knowledgeSnapshot.evidence}
|
||||
graphNodes={knowledgeSnapshot.graphNodes}
|
||||
@@ -10784,10 +10913,10 @@ function App(): React.JSX.Element {
|
||||
context={citationDialog.context}
|
||||
error={citationDialog.error}
|
||||
loading={citationDialog.loading}
|
||||
onClose={() => setCitationDialog(undefined)}
|
||||
onClose={() => { citationRequestRef.current++; setCitationDialog(undefined); }}
|
||||
onOpenSource={async () => {
|
||||
const { reference } = citationDialog;
|
||||
if (!reference.chunkId) {
|
||||
if (reference.external || !reference.chunkId || !reference.documentId) {
|
||||
throw new Error(t("chat.citations.contextUnavailable"));
|
||||
}
|
||||
await window.goodbuddy.knowledge.openReferenceSource({
|
||||
|
||||
@@ -225,10 +225,10 @@ describe('ChatTimeline', () => {
|
||||
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
|
||||
rerender(<ChatTimeline {...props} messages={[{
|
||||
...message, status: 'OpenCode 正在处理请求',
|
||||
question: {
|
||||
pendingQuestions: [{
|
||||
requestId: 'question-request', type: 'question', questionId: 'q-1',
|
||||
questions: [{ header: '确认', question: '继续吗?', options: [], custom: true, multiple: false }]
|
||||
}
|
||||
}]
|
||||
}]} />)
|
||||
expect(screen.getByRole('status', { name: '等待你的回答' })).toBeVisible()
|
||||
expect(screen.queryByText('OpenCode 正在处理请求')).not.toBeInTheDocument()
|
||||
|
||||
@@ -69,7 +69,7 @@ export type Message = {
|
||||
argumentSummary?: string
|
||||
allowPermanent?: boolean
|
||||
}
|
||||
question?: Extract<AgentEvent, { type: 'question' }>
|
||||
pendingQuestions?: Extract<AgentEvent, { type: 'question' }>[]
|
||||
answeredQuestions?: ConversationMessage['answeredQuestions']
|
||||
sources?: string[]
|
||||
sourceReferences?: KnowledgeSearchReference[]
|
||||
@@ -297,10 +297,12 @@ function ToolExecutionList({
|
||||
const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
renderHtml,
|
||||
subagent,
|
||||
questionFormId,
|
||||
onCopy
|
||||
}: {
|
||||
renderHtml: boolean
|
||||
subagent: SubagentActivity
|
||||
questionFormId?: string
|
||||
onCopy: CopyContent
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
@@ -371,11 +373,11 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</small>
|
||||
</span>
|
||||
<span
|
||||
aria-label={t(`chat.subagents.states.${subagent.state}`)}
|
||||
aria-label={questionFormId ? t('chat.status.waitingForAnswer') : t(`chat.subagents.states.${subagent.state}`)}
|
||||
className={`subagent-status-card__status subagent-status-card__status--${subagent.state}`}
|
||||
>
|
||||
<StateIcon aria-hidden="true" size={14} />
|
||||
{t(`chat.subagents.states.${subagent.state}`)}
|
||||
{questionFormId ? t('chat.status.waitingForAnswer') : t(`chat.subagents.states.${subagent.state}`)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
@@ -385,6 +387,15 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</summary>
|
||||
{expanded && (
|
||||
<div className="subagent-status-card__details">
|
||||
{questionFormId && (
|
||||
<button className="secondary-button" type="button" onClick={() => {
|
||||
const form = document.getElementById(questionFormId)
|
||||
form?.scrollIntoView({ block: 'center' })
|
||||
form?.focus({ preventScroll: true })
|
||||
}}>
|
||||
{t('workspace:question.locate')}
|
||||
</button>
|
||||
)}
|
||||
{progress && progress.length > 0 && (
|
||||
<section aria-label={t('chat.subagents.progress')}>
|
||||
<strong>{t('chat.subagents.progress')}</strong>
|
||||
@@ -446,10 +457,14 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
function SubagentStatusList({
|
||||
renderHtml,
|
||||
subagents,
|
||||
pendingQuestions,
|
||||
questionFormId,
|
||||
onCopy
|
||||
}: {
|
||||
renderHtml: boolean
|
||||
subagents: SubagentActivity[]
|
||||
pendingQuestions?: Message['pendingQuestions']
|
||||
questionFormId?: string
|
||||
onCopy: CopyContent
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
@@ -464,6 +479,7 @@ function SubagentStatusList({
|
||||
key={subagent.childTaskId}
|
||||
renderHtml={renderHtml}
|
||||
subagent={subagent}
|
||||
questionFormId={pendingQuestions?.some((question) => question.childTaskId === subagent.childTaskId) ? questionFormId : undefined}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
))}
|
||||
@@ -573,6 +589,9 @@ function ChatMessageRowView({
|
||||
? t('chat.contextCompression.agentFailed')
|
||||
: t('chat.contextCompression.failed')
|
||||
|
||||
const question = message.pendingQuestions?.[0]
|
||||
const questionTask = question?.childTaskId ? subagentsById.get(question.childTaskId) : undefined
|
||||
const questionFormId = `agent-question-${message.id}`
|
||||
const activeTool = message.tools?.find((tool) =>
|
||||
tool.state === 'pending' || tool.state === 'running')
|
||||
const activeSubagent = message.subagents?.some((subagent) =>
|
||||
@@ -580,7 +599,7 @@ function ChatMessageRowView({
|
||||
const activeCompression = compressionMarkers.find((marker) =>
|
||||
marker.state === 'compressing')
|
||||
const statusText = message.role === 'assistant' && message.state === 'streaming'
|
||||
? message.question
|
||||
? question
|
||||
? t('chat.status.waitingForAnswer')
|
||||
: message.approval
|
||||
? t('chat.status.waitingForApproval')
|
||||
@@ -713,6 +732,8 @@ function ChatMessageRowView({
|
||||
<ToolExecutionList key={item.id} tools={item.tools} onCopy={onCopyMessage} />
|
||||
) : item.kind === 'subagents' ? (
|
||||
<SubagentStatusList
|
||||
pendingQuestions={message.pendingQuestions}
|
||||
questionFormId={questionFormId}
|
||||
onCopy={onCopyMessage}
|
||||
key={item.id}
|
||||
renderHtml={renderAssistantHtml}
|
||||
@@ -771,6 +792,8 @@ function ChatMessageRowView({
|
||||
)}
|
||||
{unorderedSubagents && unorderedSubagents.length > 0 && (
|
||||
<SubagentStatusList
|
||||
pendingQuestions={message.pendingQuestions}
|
||||
questionFormId={questionFormId}
|
||||
onCopy={onCopyMessage}
|
||||
renderHtml={renderAssistantHtml}
|
||||
subagents={unorderedSubagents}
|
||||
@@ -923,6 +946,7 @@ function ChatMessageRowView({
|
||||
<small>{reference.locator}</small>
|
||||
)}
|
||||
<p>{reference.snippet}</p>
|
||||
{reference.external && <small>{reference.libraryName} · {reference.external.provider} · {reference.sourceName}</small>}
|
||||
{reference.retrievalChannels && (
|
||||
<small>
|
||||
{t('chat.citations.retrieval')}
|
||||
@@ -956,16 +980,16 @@ function ChatMessageRowView({
|
||||
>
|
||||
{t('chat.citations.viewContext')}
|
||||
</button>
|
||||
<button
|
||||
{!reference.external && <button
|
||||
className="secondary-button"
|
||||
disabled={!reference.chunkId}
|
||||
disabled={!reference.chunkId || !reference.documentId}
|
||||
onClick={() =>
|
||||
void onOpenCitationSource(reference)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.citations.openSource')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
@@ -1061,25 +1085,29 @@ function ChatMessageRowView({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.question && (
|
||||
{question && (
|
||||
<AgentQuestionCard
|
||||
key={message.question.questionId}
|
||||
id={questionFormId}
|
||||
pendingCount={message.pendingQuestions?.length}
|
||||
taskTitle={questionTask?.reason ??
|
||||
(questionTask && 'expertName' in questionTask ? questionTask.expertName : undefined) ?? message.task?.title}
|
||||
key={question.questionId}
|
||||
onReject={() =>
|
||||
onRespondQuestion(
|
||||
conversationId,
|
||||
message.id,
|
||||
message.question!.questionId
|
||||
question.questionId
|
||||
)
|
||||
}
|
||||
onSubmit={(answers) =>
|
||||
onRespondQuestion(
|
||||
conversationId,
|
||||
message.id,
|
||||
message.question!.questionId,
|
||||
question.questionId,
|
||||
answers
|
||||
)
|
||||
}
|
||||
value={message.question}
|
||||
value={question}
|
||||
/>
|
||||
)}
|
||||
{message.answeredQuestions?.map((review) => (
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DesktopApi, KnowledgeLibrary, KnowledgeSnapshot } from '../../shared/contracts'
|
||||
import type { ExternalKnowledgeInstanceSummary, ExternalKnowledgeCatalogPage } from '../../shared/external-knowledge-contracts'
|
||||
import { ExternalBindingForm, ExternalInstanceManager, ExternalLibraryDetail, externalProviderDefaults } from './ExternalKnowledge'
|
||||
import i18n from './i18n'
|
||||
|
||||
const instance: ExternalKnowledgeInstanceSummary = { id: 'instance-1', name: 'Company knowledge', provider: 'dify', baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured', probeStatus: 'healthy', bindingCount: 0 }
|
||||
const library: KnowledgeLibrary = { id: 'external-1', name: 'Handbook', description: '', storageMode: 'managed', graphEnabled: false, graphStrategy: 'model', sourceCount: 0, documentCount: 0, indexedDocumentCount: 0, external: { knowledgeBaseId: 'external-1', instanceId: instance.id, provider: 'dify', remoteKnowledgeBaseId: 'remote-1', remoteName: 'Handbook', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: externalProviderDefaults('dify'), lastVerifiedAt: '2026-09-12' } }
|
||||
const snapshot: KnowledgeSnapshot = { libraries: [library], sources: [], documents: [], graphNodes: [], graphRelations: [], evidence: [] }
|
||||
const bridge = {
|
||||
externalCatalogList: vi.fn(async (): Promise<ExternalKnowledgeCatalogPage> => ({ items: [{ id: 'remote-1', name: 'Handbook' }], hasMore: false })),
|
||||
externalCatalogGet: vi.fn(async () => ({ id: 'remote-1', name: 'Handbook' })),
|
||||
externalRetrievalTest: vi.fn(async () => ({ results: [], durationMs: 10 })),
|
||||
externalBindingsCreate: vi.fn(async () => snapshot),
|
||||
externalBindingsUpdate: vi.fn(async () => snapshot),
|
||||
externalInstancesSave: vi.fn(async () => instance),
|
||||
externalInstancesTest: vi.fn(async () => instance),
|
||||
externalInstancesDelete: vi.fn(async () => undefined),
|
||||
deleteLibrary: vi.fn<(id: string) => Promise<KnowledgeSnapshot>>(async () => snapshot)
|
||||
}
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
await i18n.changeLanguage('en-US')
|
||||
window.goodbuddy = { knowledge: bridge } as unknown as DesktopApi
|
||||
})
|
||||
afterEach(() => { cleanup(); void i18n.changeLanguage('zh-CN') })
|
||||
function selectInstance(index = 0): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'External instance' }))
|
||||
fireEvent.click(screen.getAllByRole('menuitemradio')[index]!)
|
||||
}
|
||||
|
||||
describe('external knowledge workflows', () => {
|
||||
it('navigates the rich instance menu, skips disabled items and restores focus', async () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'disabled', name: 'Disabled service', enabled: false }, { ...instance, id: 'other', name: 'Other service' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
const trigger = screen.getByRole('button', { name: 'External instance' })
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
const menu = screen.getByRole('menu')
|
||||
await waitFor(() => expect(screen.getByRole('menuitemradio', { name: /Company knowledge/ })).toHaveFocus())
|
||||
expect(screen.getByRole('menuitemradio', { name: /Disabled service/ })).toBeDisabled()
|
||||
fireEvent.keyDown(menu, { key: 'ArrowDown' })
|
||||
expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveFocus()
|
||||
fireEvent.keyDown(menu, { key: 'Home' })
|
||||
expect(screen.getByRole('menuitemradio', { name: /Company knowledge/ })).toHaveFocus()
|
||||
fireEvent.keyDown(menu, { key: 'End' })
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Other service/ }))
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
await waitFor(() => expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveFocus())
|
||||
expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveAttribute('aria-checked', 'true')
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers clear search and manual entry for distinct empty catalog states', async () => {
|
||||
bridge.externalCatalogList.mockResolvedValueOnce({ items: [], hasMore: false }).mockResolvedValueOnce({ items: [], hasMore: false })
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
expect((await screen.findByText('No accessible libraries in this instance')).closest('.empty-state')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Search remote libraries'), { target: { value: 'missing' } })
|
||||
expect((await screen.findByText('No matching results')).closest('.empty-state')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.getByLabelText('Search remote libraries')).toHaveValue('')
|
||||
await screen.findByRole('button', { name: /Handbook/ })
|
||||
})
|
||||
|
||||
it('can dismiss an instance menu when every instance is disabled', () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[{ ...instance, enabled: false }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
const trigger = screen.getByRole('button', { name: 'External instance' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('menuitemradio')).toBeDisabled()
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('labels saving separately from testing and formats verification timestamps', async () => {
|
||||
let finish!: (value: KnowledgeSnapshot) => void
|
||||
bridge.externalBindingsUpdate.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
render(<ExternalLibraryDetail library={library} instances={[instance]} libraries={[library]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
|
||||
expect(document.querySelector('time')).toHaveAttribute('datetime', '2026-09-12')
|
||||
expect(document.querySelector('time')).not.toHaveTextContent('2026-09-12')
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Retrieval settings' }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save retrieval settings' }))
|
||||
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Testing…' })).not.toBeInTheDocument()
|
||||
await act(async () => finish(snapshot))
|
||||
})
|
||||
|
||||
it('keeps external tabs and filters from shrinking in the scrolling workspace', () => {
|
||||
const css = readFileSync('src/renderer/src/external-knowledge.css', 'utf8')
|
||||
expect(css).toMatch(/\.external-knowledge > \.page-tabs\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.external-knowledge__filters\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.external-knowledge-modal__surface\s*\{[^}]*width: min\(560px, 100%\)/)
|
||||
expect(css).toMatch(/\.external-knowledge__footer\s*\{[^}]*position: sticky/)
|
||||
expect(css).toMatch(/\.external-knowledge \.external-knowledge\s*\{[^}]*overflow: visible/)
|
||||
expect(css).not.toMatch(/\.external-knowledge__catalog\s*\{[^}]*overflow: auto/)
|
||||
expect(css).toContain(".external-knowledge__target:hover:not(:disabled):not([aria-pressed='true'])")
|
||||
expect(css).toMatch(/\.external-knowledge__fieldset :is\(input, select, textarea, summary\)\s*\{[^}]*scroll-margin-block:/)
|
||||
})
|
||||
|
||||
it('uses shared fields in the portal with one create credential and an adjacent visibility button', async () => {
|
||||
const view = render(<ExternalInstanceManager instances={[]} libraries={[]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add instance' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Add instance' })
|
||||
expect(view.container).not.toContainElement(dialog)
|
||||
expect(screen.getByLabelText('Name').closest('label')).toHaveClass('field')
|
||||
expect(screen.getByLabelText('Provider').closest('label')).toHaveClass('field')
|
||||
expect(screen.queryByLabelText('Saved credential')).not.toBeInTheDocument()
|
||||
const key = screen.getByLabelText('API Key')
|
||||
expect(key).toHaveAttribute('type', 'password')
|
||||
const reveal = screen.getByRole('button', { name: 'Show credential' })
|
||||
expect(key.parentElement).toContainElement(reveal)
|
||||
fireEvent.click(reveal)
|
||||
expect(key).toHaveAttribute('type', 'text')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide credential' }))
|
||||
expect(key).toHaveAttribute('type', 'password')
|
||||
const enabled = screen.getByRole('switch')
|
||||
expect(enabled.previousElementSibling).toHaveTextContent('Enable instance')
|
||||
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'https://knowledge.example' } })
|
||||
expect(screen.queryByText(/without transport encryption/)).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'http://knowledge.example' } })
|
||||
expect(screen.getByLabelText('Service URL')).toHaveAccessibleDescription('This HTTP address sends credentials and queries without transport encryption.')
|
||||
expect(screen.getByText('How to test the connection').closest('details')).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('blocks duplicate manual IDs before sending a retrieval request', async () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
|
||||
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: ' remote-1 ' } })
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Already added')
|
||||
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
|
||||
expect(bridge.externalRetrievalTest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards earlier success when a retest fails or the instance changes', async () => {
|
||||
const props = { provider: 'dify' as const, instances: [instance], libraries: [], onCancel: vi.fn(), onChanged: vi.fn(), notify: vi.fn(), onManage: vi.fn() }
|
||||
const view = render(<ExternalBindingForm {...props} />)
|
||||
selectInstance()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
|
||||
bridge.externalRetrievalTest.mockRejectedValueOnce(new Error('Remote timeout'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Remote timeout')
|
||||
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
|
||||
view.rerender(<ExternalBindingForm {...props} instances={[{ ...instance, baseUrl: 'https://other.example' }]} />)
|
||||
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('rebinds a manual target without retaining the previous remote name', async () => {
|
||||
bridge.externalCatalogGet.mockRejectedValueOnce(new Error('No detail endpoint')).mockRejectedValueOnce(new Error('No detail endpoint'))
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} library={library} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
|
||||
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: 'new-target' } })
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'New target name' } })
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await screen.findByText(/Retrieval succeeded with 0 snippets/)
|
||||
expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save retrieval settings' }))
|
||||
await waitFor(() => expect(bridge.externalBindingsUpdate).toHaveBeenCalledWith(expect.objectContaining({ knowledgeBaseId: library.id, remoteKnowledgeBaseId: 'new-target', remoteName: 'New target name' })))
|
||||
})
|
||||
|
||||
it('allows disabling saved RAGFlow capabilities that are no longer available', async () => {
|
||||
const ragLibrary = { ...library, external: { ...library.external!, provider: 'ragflow' as const, providerConfig: { provider: 'ragflow' as const, similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 1024, useKg: true, includeKnowledgeCompilation: true } } }
|
||||
render(<ExternalBindingForm provider="ragflow" instances={[{ ...instance, provider: 'ragflow' }]} libraries={[ragLibrary]} library={ragLibrary} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Graph retrieval' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Include Knowledge Compilation' }))
|
||||
expect(screen.getByRole('switch', { name: 'Graph retrieval' })).not.toBeChecked()
|
||||
expect(screen.getByRole('switch', { name: 'Graph retrieval' })).toBeDisabled()
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ useKg: false, includeKnowledgeCompilation: false }) })))
|
||||
})
|
||||
|
||||
it('clears incomplete hidden Dify reranking fields when reranking is disabled', async () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} library={library} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Use dataset defaults' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Enable Rerank' }))
|
||||
fireEvent.change(screen.getByLabelText('Reranking provider'), { target: { value: 'provider' } })
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Enable Rerank' }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ retrievalModel: expect.objectContaining({ reranking_enable: false, reranking_model: undefined }) }) })))
|
||||
})
|
||||
|
||||
it('requires explicit credential clearing and retains the saved credential by default', async () => {
|
||||
render(<ExternalInstanceManager instances={[instance]} libraries={[library]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByLabelText('Saved credential')).toHaveValue('keep')
|
||||
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Saved credential'), { target: { value: 'replace' } })
|
||||
expect(screen.getByLabelText('API Key')).toHaveValue('')
|
||||
fireEvent.change(screen.getByLabelText('Saved credential'), { target: { value: 'clear' } })
|
||||
expect(screen.getByRole('button', { name: 'Save instance' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save instance' }))
|
||||
await waitFor(() => expect(bridge.externalInstancesSave).toHaveBeenCalledWith(expect.objectContaining({ id: instance.id, credential: { action: 'clear' } })))
|
||||
})
|
||||
|
||||
it('accepts the FastGPT API token range and restores defaults only in the draft', async () => {
|
||||
const fastLibrary = { ...library, external: { ...library.external!, provider: 'fastgpt' as const, providerConfig: externalProviderDefaults('fastgpt') } }
|
||||
render(<ExternalBindingForm provider="fastgpt" instances={[{ ...instance, provider: 'fastgpt' }]} libraries={[fastLibrary]} library={fastLibrary} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
fireEvent.change(screen.getByLabelText('Token limit'), { target: { value: '30000' } })
|
||||
expect(screen.getByLabelText('Token limit')).toBeValid()
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await screen.findByText(/Retrieval succeeded with 0 snippets/)
|
||||
expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ tokenLimit: 30000 }) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restore defaults' }))
|
||||
expect(screen.getByLabelText('Token limit')).toHaveValue(5000)
|
||||
expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeDisabled()
|
||||
expect(bridge.externalBindingsUpdate).not.toHaveBeenCalled()
|
||||
expect(bridge.externalInstancesSave).not.toHaveBeenCalled()
|
||||
expect(screen.getByText(/Settings are saved only in GoodBuddy/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refreshes completed removals after a later binding deletion fails', async () => {
|
||||
const onChanged = vi.fn()
|
||||
bridge.deleteLibrary.mockResolvedValueOnce(snapshot).mockRejectedValueOnce(new Error('Binding removal failed'))
|
||||
render(<ExternalInstanceManager instances={[{ ...instance, bindingCount: 2 }]} libraries={[library, { ...library, id: 'external-2', name: 'Second binding' }]} onClose={vi.fn()} onChanged={onChanged} notify={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(screen.getByRole('dialog')).toHaveAccessibleName('Delete instance “Company knowledge”?')
|
||||
expect(screen.getByRole('button', { name: 'Delete instance and local bindings' })).toHaveClass('danger-button')
|
||||
expect(screen.getByText('Handbook')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second binding')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Cancel' }).find(button => button.textContent === 'Cancel')).toHaveFocus()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete instance and local bindings' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Binding removal failed')
|
||||
expect(onChanged).toHaveBeenCalledOnce()
|
||||
expect(bridge.externalInstancesDelete).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Delete instance and local bindings' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('prevents retrieval through disabled instances', () => {
|
||||
render(<ExternalLibraryDetail library={library} libraries={[library]} instances={[{ ...instance, enabled: false }]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Test retrieval' }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Instance disabled')
|
||||
})
|
||||
|
||||
it.each(['dify', 'fastgpt', 'ragflow'] as const)('creates a %s binding after a zero-result retrieval', async provider => {
|
||||
const onChanged = vi.fn()
|
||||
render(<ExternalBindingForm provider={provider} instances={[{ ...instance, provider }]} libraries={[]} onCancel={vi.fn()} onChanged={onChanged} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
for (const control of document.querySelectorAll('input:not([type="checkbox"]), select, textarea')) {
|
||||
expect(control.closest('label')).toHaveClass('field')
|
||||
}
|
||||
for (const toggle of screen.getAllByRole('switch')) {
|
||||
expect(toggle.previousElementSibling?.tagName).toBe('SPAN')
|
||||
expect(toggle.closest('label')).toHaveClass('toggle-row')
|
||||
}
|
||||
selectInstance()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'network policy' } })
|
||||
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add library' }))
|
||||
await waitFor(() => expect(bridge.externalBindingsCreate).toHaveBeenCalledWith(expect.objectContaining({ instanceId: instance.id, remoteKnowledgeBaseId: 'remote-1', testQuery: 'network policy', providerConfig: expect.objectContaining({ provider }) })))
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledWith(snapshot, library.id))
|
||||
})
|
||||
|
||||
it('retains a manual target and query after retrieval fails', async () => {
|
||||
bridge.externalRetrievalTest.mockRejectedValueOnce(new Error('Authentication failed'))
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
|
||||
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: 'manual-id' } })
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'keep this query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Authentication failed')
|
||||
expect(screen.getByLabelText('Remote library ID')).toHaveValue('manual-id')
|
||||
expect(screen.getByLabelText('Test query')).toHaveValue('keep this query')
|
||||
})
|
||||
|
||||
it('ignores an old catalog when the instance changes', async () => {
|
||||
let resolve!: (page: ExternalKnowledgeCatalogPage) => void
|
||||
bridge.externalCatalogList.mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'instance-2' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
await waitFor(() => expect(bridge.externalCatalogList).toHaveBeenCalledOnce())
|
||||
selectInstance(1)
|
||||
await screen.findByRole('button', { name: /Handbook/ })
|
||||
await act(async () => resolve({ items: [{ id: 'old', name: 'Stale catalog' }], hasMore: false }))
|
||||
expect(screen.queryByText('Stale catalog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tests saved configuration without exposing save or target editing in the test tab', async () => {
|
||||
render(<ExternalLibraryDetail library={library} libraries={[library]} instances={[instance]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Test retrieval' }))
|
||||
expect(screen.queryByRole('button', { name: 'Save retrieval settings' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('External instance')).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'saved config query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await screen.findByText(/Retrieval succeeded with 0 snippets/)
|
||||
expect(bridge.externalBindingsUpdate).not.toHaveBeenCalled()
|
||||
expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ commonConfig: library.external!.commonConfig, providerConfig: library.external!.providerConfig }))
|
||||
})
|
||||
|
||||
it('saves an instance without an all-address consent gate and preserves failed drafts', async () => {
|
||||
bridge.externalInstancesSave.mockRejectedValueOnce(new Error('Save failed'))
|
||||
render(<ExternalInstanceManager instances={[]} libraries={[]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add instance' }))
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Company' } })
|
||||
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'https://knowledge.example' } })
|
||||
fireEvent.change(screen.getAllByLabelText('API Key').find(element => element.tagName === 'INPUT')!, { target: { value: 'test-key' } })
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save instance' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save instance' })).toBeEnabled())
|
||||
expect(screen.getByLabelText('Service URL')).toHaveValue('https://knowledge.example')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,467 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDown, Database, Eye, EyeOff, FolderOpen, RefreshCw, X } from 'lucide-react'
|
||||
import type { KnowledgeLibrary, KnowledgeSnapshot } from '../../shared/contracts'
|
||||
import {
|
||||
externalKnowledgeBindingTestInputSchema,
|
||||
externalKnowledgeInstanceSaveInputSchema,
|
||||
type ExternalKnowledgeProvider,
|
||||
type ExternalKnowledgeProviderConfig,
|
||||
type ExternalKnowledgeCommonConfig,
|
||||
type ExternalKnowledgeCatalogItem,
|
||||
type ExternalKnowledgeCatalogPage,
|
||||
type ExternalKnowledgeInstanceSaveInput,
|
||||
type ExternalKnowledgeInstanceSummary,
|
||||
type ExternalKnowledgeTestResult
|
||||
} from '../../shared/external-knowledge-contracts'
|
||||
import { EmptyState, PageTabs, ScopeBadge, SegmentedControl } from './WorkspacePrimitives'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import './external-knowledge.css'
|
||||
|
||||
export const externalProviderNames = { dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' }
|
||||
export function externalLibraryStatus(library: KnowledgeLibrary, instances: ExternalKnowledgeInstanceSummary[]): 'ready' | 'untested' | 'instance-disabled' | 'credential-error' | 'temporarily-unavailable' {
|
||||
const instance = instances.find(item => item.id === library.external?.instanceId)
|
||||
if (!instance) return 'temporarily-unavailable'
|
||||
if (!instance.enabled) return 'instance-disabled'
|
||||
if (instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed') return 'credential-error'
|
||||
if (['failed', 'unreachable'].includes(instance.probeStatus)) return 'temporarily-unavailable'
|
||||
return library.external?.lastVerifiedAt ? 'ready' : 'untested'
|
||||
}
|
||||
type Notify = (input: AppNotificationInput) => void
|
||||
type Changed = (snapshot?: KnowledgeSnapshot, createdId?: string) => void | Promise<void>
|
||||
const commonDefaults: ExternalKnowledgeCommonConfig = { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }
|
||||
export function externalProviderDefaults(provider: ExternalKnowledgeProvider): ExternalKnowledgeProviderConfig {
|
||||
if (provider === 'dify') return { provider, useDatasetDefaults: true }
|
||||
if (provider === 'fastgpt') return { provider, searchMode: 'embedding', tokenLimit: 5000, similarity: 0, usingRerank: false }
|
||||
return { provider, similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 1024, useKg: false, includeKnowledgeCompilation: false }
|
||||
}
|
||||
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error)
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }): React.JSX.Element {
|
||||
return <label className="field"><span>{label}</span>{children}</label>
|
||||
}
|
||||
function Switch({ label, checked, disabled, onChange }: { label: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }): React.JSX.Element {
|
||||
return <label className="toggle-row"><span>{label}</span><input type="checkbox" role="switch" checked={checked} disabled={disabled} onChange={event => onChange(event.currentTarget.checked)} /></label>
|
||||
}
|
||||
|
||||
function InstanceSelect({ instances, value, onChange }: { instances: ExternalKnowledgeInstanceSummary[]; value: string; onChange: (value: string) => void }): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [open, setOpen] = useState(false)
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const menu = useRef<HTMLDivElement>(null)
|
||||
const id = useId()
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const items = Array.from(menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])
|
||||
const selected = items.find(item => item.getAttribute('aria-checked') === 'true') ?? items[0]
|
||||
items.forEach(item => { item.tabIndex = item === selected ? 0 : -1 })
|
||||
selected?.focus()
|
||||
})
|
||||
const dismiss = (event: Event): void => { if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false) }
|
||||
document.addEventListener('pointerdown', dismiss)
|
||||
document.addEventListener('focusin', dismiss)
|
||||
return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', dismiss); document.removeEventListener('focusin', dismiss) }
|
||||
}, [open])
|
||||
return <div className="field external-knowledge__instance-select" ref={root}>
|
||||
<span id={`${id}-label`}>{t('external.instance')}</span>
|
||||
<button type="button" className="model-button" ref={trigger} aria-labelledby={`${id}-label`} aria-describedby={`${id}-value`} aria-expanded={open} aria-haspopup="menu" aria-controls={open ? id : undefined} onClick={() => setOpen(!open)} onKeyDown={event => {
|
||||
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); setOpen(true) }
|
||||
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); setOpen(false) }
|
||||
}}><span className="model-button__label" id={`${id}-value`}>{instances.find(item => item.id === value)?.name ?? t('external.selectInstance')}</span><ChevronDown size={14} aria-hidden="true" /></button>
|
||||
{open && <div className="runtime-picker__menu composer-picker__menu" role="menu" aria-labelledby={`${id}-label`} id={id} ref={menu} onKeyDown={event => {
|
||||
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'))
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : event.key === 'ArrowDown' ? (index + 1) % items.length : event.key === 'ArrowUp' ? (index - 1 + items.length) % items.length : undefined
|
||||
if (next !== undefined && items[next]) { event.preventDefault(); items.forEach((item, i) => { item.tabIndex = i === next ? 0 : -1 }); items[next].focus() }
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus() }
|
||||
}}>
|
||||
{!instances.length && <p className="external-knowledge__help">{t('external.noInstancesHelp')}</p>}
|
||||
{instances.map(item => <button type="button" role="menuitemradio" key={item.id} aria-checked={item.id === value} disabled={!item.enabled || item.credentialStatus !== 'configured'} tabIndex={-1} onClick={() => { onChange(item.id); setOpen(false); trigger.current?.focus() }}>
|
||||
<span>{item.name}</span><small>{new URL(item.baseUrl).origin} · {item.enabled ? t(`external.credentials.${item.credentialStatus}`) : t('external.states.instance-disabled')}</small>
|
||||
</button>)}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ExternalTime({ value }: { value: string }): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return <>{t('format.unknownTime')}</>
|
||||
const label = date.toLocaleString(i18n.resolvedLanguage, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
return <time dateTime={value} title={date.toLocaleString(i18n.resolvedLanguage)}>{label}</time>
|
||||
}
|
||||
|
||||
export function ExternalConfig({ common, config, detail, onCommon, onConfig }: {
|
||||
common: ExternalKnowledgeCommonConfig; config: ExternalKnowledgeProviderConfig; detail?: ExternalKnowledgeCatalogItem
|
||||
onCommon: (value: ExternalKnowledgeCommonConfig) => void; onConfig: (value: ExternalKnowledgeProviderConfig) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const number = (label: string, value: number, min: number, max: number, change: (value: number) => void, step = 1): ReactNode =>
|
||||
<Field label={label}><input type="number" required min={min} max={max} step={step} value={value} onChange={event => change(event.currentTarget.valueAsNumber)} /></Field>
|
||||
const text = (label: string, value: string, change: (value: string) => void): ReactNode =>
|
||||
<Field label={label}><input required value={value} onChange={event => change(event.currentTarget.value)} /></Field>
|
||||
return <section className="external-knowledge__fields">
|
||||
<fieldset className="external-knowledge__group">
|
||||
<legend>{t('external.common')}</legend>
|
||||
{number(t('external.resultLimit'), common.resultLimit, 1, 20, value => onCommon({ ...common, resultLimit: value }))}
|
||||
{number(t('external.timeout'), common.requestTimeoutMs, 1000, 60000, value => onCommon({ ...common, requestTimeoutMs: value }), 1000)}
|
||||
{number(t('external.snippetLimit'), common.maxSnippetCharacters, 100, 8000, value => onCommon({ ...common, maxSnippetCharacters: value }))}
|
||||
</fieldset>
|
||||
<fieldset className="external-knowledge__group">
|
||||
<legend>{externalProviderNames[config.provider]} · {t('external.config')}</legend>
|
||||
{config.provider === 'dify' && <>
|
||||
<Switch label={t('external.datasetDefaults')} checked={config.useDatasetDefaults} onChange={value => onConfig(value ? { provider: 'dify', useDatasetDefaults: true } : { provider: 'dify', useDatasetDefaults: false, retrievalModel: { search_method: 'semantic_search', top_k: 6, reranking_enable: false, score_threshold_enabled: false } })} />
|
||||
{config.useDatasetDefaults ? <p className="knowledge-muted">{t('external.datasetDefaultsHelp')}</p> : (() => {
|
||||
const model = config.retrievalModel
|
||||
const update = (value: Partial<typeof model>): void => onConfig({ ...config, retrievalModel: { ...model, ...value } })
|
||||
return <>
|
||||
<Field label={t('external.searchMethod')}><select value={model.search_method} onChange={event => update({ search_method: event.currentTarget.value as typeof model.search_method })}>{['keyword_search', 'semantic_search', 'full_text_search', 'hybrid_search'].map(value => <option key={value}>{value}</option>)}</select></Field>
|
||||
{number(t('external.topK'), model.top_k, 1, 20, value => update({ top_k: value }))}
|
||||
<Switch label={t('external.thresholdEnabled')} checked={model.score_threshold_enabled} onChange={value => update({ score_threshold_enabled: value, score_threshold: model.score_threshold ?? 0.5 })} />
|
||||
{model.score_threshold_enabled && number(t('external.threshold'), model.score_threshold ?? 0.5, 0, 1, value => update({ score_threshold: value }), 0.01)}
|
||||
<Switch label={t('external.rerank')} checked={model.reranking_enable} onChange={value => update(value ? { reranking_enable: true, reranking_mode: model.reranking_mode ?? 'reranking_model' } : { reranking_enable: false, reranking_mode: undefined, reranking_model: undefined, weights: undefined })} />
|
||||
{model.reranking_enable && <>
|
||||
<Field label={t('external.rerankMode')}><select value={model.reranking_mode ?? 'reranking_model'} onChange={event => update({ reranking_mode: event.currentTarget.value as 'weighted_score' | 'reranking_model', reranking_model: undefined, weights: undefined })}><option value="reranking_model">{t('external.rerankModel')}</option><option value="weighted_score">{t('external.weightedScore')}</option></select></Field>
|
||||
{model.reranking_mode !== 'weighted_score' ? <>
|
||||
{text(t('external.rerankProvider'), model.reranking_model?.reranking_provider_name ?? '', value => update({ reranking_model: { reranking_provider_name: value, reranking_model_name: model.reranking_model?.reranking_model_name ?? '' } }))}
|
||||
{text(t('external.rerankModel'), model.reranking_model?.reranking_model_name ?? '', value => update({ reranking_model: { reranking_provider_name: model.reranking_model?.reranking_provider_name ?? '', reranking_model_name: value } }))}
|
||||
</> : (() => {
|
||||
const weights = model.weights ?? { weight_type: 'customized' as const, vector_setting: { vector_weight: 0.5, embedding_provider_name: '', embedding_model_name: '' }, keyword_setting: { keyword_weight: 0.5 } }
|
||||
return <>
|
||||
{number(t('external.vectorWeight'), weights.vector_setting.vector_weight, 0, 1, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, vector_weight: value }, keyword_setting: { keyword_weight: 1 - value } } }), 0.01)}
|
||||
<p>{t('external.keywordWeight')}: {Number(weights.keyword_setting.keyword_weight.toFixed(2))}</p>
|
||||
{text(t('external.embeddingProvider'), weights.vector_setting.embedding_provider_name, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, embedding_provider_name: value } } }))}
|
||||
{text(t('external.embeddingModel'), weights.vector_setting.embedding_model_name, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, embedding_model_name: value } } }))}
|
||||
</>
|
||||
})()}
|
||||
</>}
|
||||
</>
|
||||
})()}
|
||||
</>}
|
||||
{config.provider === 'fastgpt' && <>
|
||||
<Field label={t('external.searchMethod')}><select value={config.searchMode} onChange={event => onConfig({ ...config, searchMode: event.currentTarget.value as typeof config.searchMode })}>{['embedding', 'fullTextRecall', 'mixedRecall'].map(value => <option key={value}>{value}</option>)}</select></Field>
|
||||
{number(t('external.tokenLimit'), config.tokenLimit, 1, 30000, value => onConfig({ ...config, tokenLimit: value }))}
|
||||
{number(t('external.threshold'), config.similarity, 0, 1, value => onConfig({ ...config, similarity: value }), 0.01)}
|
||||
<Switch label={t('external.rerank')} checked={config.usingRerank} onChange={value => onConfig({ ...config, usingRerank: value })} />
|
||||
</>}
|
||||
{config.provider === 'ragflow' && <>
|
||||
{number(t('external.threshold'), config.similarityThreshold, 0, 1, value => onConfig({ ...config, similarityThreshold: value }), 0.01)}
|
||||
{number(t('external.vectorWeight'), config.vectorSimilarityWeight, 0, 1, value => onConfig({ ...config, vectorSimilarityWeight: value }), 0.01)}
|
||||
{number(t('external.knnTopK'), config.knnTopK, 1, 2048, value => onConfig({ ...config, knnTopK: value }))}
|
||||
<Switch label={t('external.graph')} checked={config.useKg} disabled={!config.useKg && !detail?.graphEnabled} onChange={value => onConfig({ ...config, useKg: value })} />
|
||||
<p className="knowledge-muted">{t(detail?.graphEnabled ? 'external.graphHelp' : 'external.graphUnknown')}</p>
|
||||
<Switch label={t('external.compilation')} checked={config.includeKnowledgeCompilation} disabled={!config.includeKnowledgeCompilation && !detail?.knowledgeCompilationEnabled} onChange={value => onConfig({ ...config, includeKnowledgeCompilation: value })} />
|
||||
{!detail?.knowledgeCompilationEnabled && <p className="knowledge-muted">{t('external.compilationUnavailable')}</p>}
|
||||
</>}
|
||||
</fieldset>
|
||||
<p className="external-knowledge__help">{t('external.localOnly')}</p>
|
||||
<button className="secondary-button" type="button" onClick={() => { onCommon(commonDefaults); onConfig(externalProviderDefaults(config.provider)) }}>{t('external.defaults')}</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
function TestResults({ result }: { result: ExternalKnowledgeTestResult }): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
return <section className="external-knowledge__results" aria-live="polite">
|
||||
<p>{t('external.tested', { count: result.results.length })} · {t('external.elapsed', { ms: result.durationMs })}</p>
|
||||
<ol>{result.results.map((item, index) => <li key={`${item.remoteChunkId ?? item.remoteDocumentId ?? index}:${index}`}>
|
||||
<strong>{item.documentTitle}</strong><small>{item.sourceDisplayName} {item.location}</small>
|
||||
<p>{item.snippet}</p>
|
||||
{item.providerScore !== undefined && <small>{t('external.providerScore')}: {item.providerScore}</small>}
|
||||
{item.providerScores?.map((score, scoreIndex) => <small key={scoreIndex}>{t('external.providerScore')} · {score.type}{score.index === undefined ? '' : ` [${score.index}]`}: {score.value}</small>)}
|
||||
{item.remoteDocumentId && <small>{t('external.documentId')}: {item.remoteDocumentId}</small>}
|
||||
{item.remoteChunkId && <small>{t('external.chunkId')}: {item.remoteChunkId}</small>}
|
||||
</li>)}</ol>
|
||||
</section>
|
||||
}
|
||||
|
||||
export function ExternalBindingForm({ provider, instances, libraries, library, onCancel, onChanged, notify, onManage }: {
|
||||
provider: ExternalKnowledgeProvider; instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; library?: KnowledgeLibrary
|
||||
onCancel: () => void; onChanged: Changed; notify: Notify; onManage: () => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const binding = library?.external
|
||||
const [instanceId, setInstanceId] = useState(binding?.instanceId ?? '')
|
||||
const [remoteId, setRemoteId] = useState(binding?.remoteKnowledgeBaseId ?? '')
|
||||
const [name, setName] = useState(library?.name ?? '')
|
||||
const [detail, setDetail] = useState<ExternalKnowledgeCatalogItem>()
|
||||
const [common, setCommon] = useState(binding?.commonConfig ?? commonDefaults)
|
||||
const [config, setConfig] = useState(binding?.providerConfig ?? externalProviderDefaults(provider))
|
||||
const [manual, setManual] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [parentId, setParentId] = useState<string | null>(null)
|
||||
const [catalog, setCatalog] = useState<ExternalKnowledgeCatalogPage>()
|
||||
const [catalogError, setCatalogError] = useState<string>()
|
||||
const [catalogLoading, setCatalogLoading] = useState(false)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [result, setResult] = useState<ExternalKnowledgeTestResult>()
|
||||
const [testedInstance, setTestedInstance] = useState<string>()
|
||||
const [rebindConfirmed, setRebindConfirmed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [operationError, setOperationError] = useState<string>()
|
||||
const generation = useRef(0)
|
||||
const errorId = useId()
|
||||
const activeInstance = instances.find(item => item.id === instanceId)
|
||||
const instanceVersion = JSON.stringify(activeInstance)
|
||||
const validInstance = activeInstance?.provider === provider && activeInstance.enabled && activeInstance.credentialStatus === 'configured'
|
||||
const verifiedResult = testedInstance === instanceVersion ? result : undefined
|
||||
const duplicate = libraries.some(item => item.id !== library?.id && item.external?.instanceId === instanceId && item.external.remoteKnowledgeBaseId === remoteId.trim())
|
||||
const invalidate = (): void => { generation.current++; setResult(undefined); setOperationError(undefined); setBusy(false); setRebindConfirmed(false) }
|
||||
useEffect(() => () => { generation.current++ }, [])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void Promise.resolve().then(async () => {
|
||||
if (!active) return
|
||||
setCatalog(undefined); setCatalogError(undefined)
|
||||
if (!instanceId || !validInstance || manual) { setCatalogLoading(false); return }
|
||||
setCatalogLoading(true)
|
||||
const value = await window.goodbuddy.knowledge.externalCatalogList({ instanceId, page, pageSize: 30, parentId, search })
|
||||
if (active) setCatalog(value)
|
||||
}).catch(error => { if (active) setCatalogError(errorMessage(error)) }).finally(() => { if (active) setCatalogLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [instanceId, validInstance, instanceVersion, page, parentId, search, manual, refresh])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!validInstance || !remoteId.trim()) return
|
||||
void window.goodbuddy.knowledge.externalCatalogGet({ instanceId, remoteKnowledgeBaseId: remoteId }).then(value => { if (active) setDetail(value) }).catch(() => { /* Retrieval remains available when a deployment has no detail endpoint. */ })
|
||||
return () => { active = false }
|
||||
}, [instanceId, remoteId, validInstance, instanceVersion, refresh])
|
||||
const testInput = { instanceId, remoteKnowledgeBaseId: remoteId, commonConfig: common, providerConfig: config, testQuery: query }
|
||||
const valid = validInstance && !duplicate && externalKnowledgeBindingTestInputSchema.safeParse(testInput).success
|
||||
const rebinding = binding && (binding.instanceId !== instanceId || binding.remoteKnowledgeBaseId !== remoteId.trim())
|
||||
const remoteName = detail?.name || (!rebinding ? binding?.remoteName : undefined) || name.trim()
|
||||
const run = async (save: boolean): Promise<void> => {
|
||||
if (busy || !valid || (save && (!verifiedResult || !name.trim() || (rebinding && !rebindConfirmed)))) return
|
||||
const current = ++generation.current
|
||||
setBusy(true)
|
||||
setSaving(save)
|
||||
setOperationError(undefined)
|
||||
try {
|
||||
if (save) {
|
||||
const input = { ...testInput, name: name.trim(), remoteName, description: library?.description }
|
||||
const snapshot = library
|
||||
? await window.goodbuddy.knowledge.externalBindingsUpdate({ ...input, knowledgeBaseId: library.id })
|
||||
: await window.goodbuddy.knowledge.externalBindingsCreate(input)
|
||||
if (current !== generation.current) return
|
||||
const created = snapshot.libraries.find(item => item.external?.instanceId === instanceId && item.external.remoteKnowledgeBaseId === remoteId.trim())
|
||||
await onChanged(snapshot, library ? undefined : created?.id)
|
||||
notify({ tone: 'success', message: t('external.saved') }); onCancel()
|
||||
} else {
|
||||
setResult(undefined)
|
||||
const value = await window.goodbuddy.knowledge.externalRetrievalTest(testInput)
|
||||
if (current === generation.current) { setResult(value); setTestedInstance(instanceVersion) }
|
||||
}
|
||||
} catch (error) { if (current === generation.current) setOperationError(errorMessage(error)) }
|
||||
finally { if (current === generation.current) setBusy(false) }
|
||||
}
|
||||
return <form className="external-knowledge" onSubmit={event => { event.preventDefault(); void run(true) }}>
|
||||
<fieldset disabled={busy} className="external-knowledge__fieldset" aria-describedby={operationError ? errorId : undefined}>
|
||||
<div className="external-knowledge__columns">
|
||||
<section className="external-knowledge__fields">
|
||||
<h3>{t('external.source')}</h3>
|
||||
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
|
||||
<button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button>
|
||||
<SegmentedControl ariaLabel={t('external.remote')} value={manual ? 'manual' : 'catalog'} options={[{ value: 'catalog', label: t('external.browse') }, { value: 'manual', label: t('external.manual') }]} onChange={value => setManual(value === 'manual')} />
|
||||
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value) }} /></Field> : <>
|
||||
<Field label={t('external.search')}><input type="search" maxLength={512} value={search} onChange={event => { setSearch(event.currentTarget.value); setPage(1) }} /></Field>
|
||||
<div className="external-knowledge__actions"><button type="button" className="secondary-button" aria-label={t('external.refresh')} title={t('external.refresh')} onClick={() => { invalidate(); setRefresh(value => value + 1) }}><RefreshCw size={15} /></button>
|
||||
{parentId && <button type="button" className="secondary-button" onClick={() => { setParentId(null); setPage(1) }}>{t('external.root')}</button>}</div>
|
||||
{catalogLoading && <p role="status">{t('external.loading')}</p>}
|
||||
{catalogError && <div role="alert"><p>{catalogError}</p><button type="button" className="secondary-button" onClick={() => setRefresh(value => value + 1)}>{t('actions.retry')}</button></div>}
|
||||
{catalog && <div className="external-knowledge__catalog" aria-label={t('external.remote')}>
|
||||
{catalog.items.length === 0 && <EmptyState icon={<Database size={20} />} title={t(search ? 'external.noResults' : 'external.empty')} description={t(search ? 'external.noResultsHelp' : 'external.emptyHelp')} action={<button type="button" className="secondary-button" onClick={() => { if (search) { setSearch(''); setPage(1) } else setManual(true) }}>{t(search ? 'external.clearSearch' : 'external.manual')}</button>} />}
|
||||
{catalog.items.map(item => {
|
||||
const bound = libraries.some(candidate => candidate.id !== library?.id && candidate.external?.instanceId === instanceId && candidate.external.remoteKnowledgeBaseId === item.id)
|
||||
return <button type="button" className="external-knowledge__target" key={item.id} aria-pressed={item.kind === 'folder' ? undefined : remoteId === item.id} disabled={bound || busy} onClick={() => {
|
||||
if (item.kind === 'folder') { setParentId(item.id); setPage(1); return }
|
||||
invalidate(); setRemoteId(item.id); setDetail(item); if (!name) setName(item.name)
|
||||
}}>{item.kind === 'folder' ? <FolderOpen size={15} /> : <Database size={15} />}<span><strong>{item.name}</strong><small>{item.id}{bound ? ` · ${t('external.alreadyBound')}` : ''}</small></span></button>
|
||||
})}
|
||||
<div className="external-knowledge__actions"><button type="button" className="secondary-button" disabled={page === 1} onClick={() => setPage(value => value - 1)}>{t('external.previous')}</button><span>{page}</span><button type="button" className="secondary-button" disabled={!catalog.hasMore} onClick={() => setPage(value => value + 1)}>{t('external.next')}</button></div>
|
||||
</div>}
|
||||
</>}
|
||||
{remoteId && <p className="external-knowledge__identifier">{remoteName}<br />{remoteId}</p>}
|
||||
{duplicate && <p role="alert" id={`${errorId}-duplicate`}>{t('external.alreadyBound')}</p>}
|
||||
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => setName(event.currentTarget.value)} /></Field>
|
||||
<Field label={t('external.query')}><textarea required maxLength={4000} value={query} onChange={event => { invalidate(); setQuery(event.currentTarget.value) }} /></Field>
|
||||
</section>
|
||||
<ExternalConfig common={common} config={config} detail={detail} onCommon={value => { invalidate(); setCommon(value) }} onConfig={value => { invalidate(); setConfig(value) }} />
|
||||
</div>
|
||||
<p className="knowledge-muted">{t('external.queryNote')}</p>
|
||||
{operationError && <p id={errorId} role="alert">{operationError}</p>}
|
||||
{verifiedResult ? <TestResults result={verifiedResult} /> : <p role="status">{t('external.untested')}</p>}
|
||||
{rebinding && <label><input type="checkbox" checked={rebindConfirmed} onChange={event => setRebindConfirmed(event.currentTarget.checked)} />{t('external.rebindConfirm')}</label>}
|
||||
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} />
|
||||
<button type="button" className="secondary-button" onClick={() => { invalidate(); onCancel() }}>{t('actions.cancel')}</button>
|
||||
<button type="button" className="secondary-button" disabled={busy || !valid} onClick={() => void run(false)}>{t(busy && !saving ? 'external.testing' : 'external.test')}</button>
|
||||
<button type="submit" className="primary-button" disabled={busy || !verifiedResult || !valid || !name.trim() || (!!rebinding && !rebindConfirmed)}>{t(busy && saving ? 'actions.saving' : library ? 'external.saveConfig' : 'external.add')}</button>
|
||||
</footer>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
|
||||
export function ExternalInstanceManager({ instances, libraries, onClose, onChanged, notify }: {
|
||||
instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; onClose: () => void; onChanged: Changed; notify: Notify
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [draft, setDraft] = useState<ExternalKnowledgeInstanceSaveInput>()
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [deleting, setDeleting] = useState<ExternalKnowledgeInstanceSummary>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [clearConfirmed, setClearConfirmed] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const generation = useRef(0)
|
||||
const keyId = useId()
|
||||
useEffect(() => activateModalFocus(() => closeRef.current), [])
|
||||
useEffect(() => () => { generation.current++ }, [])
|
||||
const run = async (action: () => Promise<unknown>, message?: string): Promise<void> => {
|
||||
const current = ++generation.current; setBusy(true)
|
||||
try { await action(); if (current !== generation.current) return; await onChanged(); if (message) notify({ tone: 'success', message }) }
|
||||
catch (error) { if (current === generation.current) notify({ tone: 'error', message: errorMessage(error) }) }
|
||||
finally { if (current === generation.current) setBusy(false) }
|
||||
}
|
||||
const edit = (instance?: ExternalKnowledgeInstanceSummary): void => {
|
||||
setDraft(instance ? { id: instance.id, name: instance.name, provider: instance.provider, baseUrl: instance.baseUrl, enabled: instance.enabled, credential: { action: 'keep' } } : { name: '', provider: 'dify', baseUrl: '', enabled: true, credential: { action: 'replace', value: '' } })
|
||||
setClearConfirmed(false); setShowKey(false)
|
||||
}
|
||||
const change = (value: Partial<ExternalKnowledgeInstanceSaveInput>): void => { if (draft) setDraft({ ...draft, ...value }); setClearConfirmed(false) }
|
||||
return createPortal(<div className="external-knowledge-modal" role="dialog" aria-modal="true" aria-labelledby="external-instance-title" ref={dialogRef} onKeyDown={event => { if (event.key === 'Escape' && !busy) { event.preventDefault(); onClose() }; trapTabFocus(event, dialogRef.current) }}>
|
||||
<section className="external-knowledge-modal__surface">
|
||||
<header><div><h2 id="external-instance-title">{deleting ? t('external.deleteInstanceTitle', { name: deleting.name }) : t(draft ? draft.id ? 'external.editInstance' : 'external.addInstance' : 'external.instances')}</h2><ScopeBadge scope={{ kind: 'global' }} /></div><button type="button" className="icon-button" ref={closeRef} disabled={busy} aria-label={t('external.close')} title={t('external.close')} onClick={onClose}><X size={16} /></button></header>
|
||||
<div className="external-knowledge-modal__body">
|
||||
{draft ? <form className="external-knowledge__fields" onSubmit={event => { event.preventDefault(); if (busy || (draft.credential.action === 'clear' && !clearConfirmed)) return; void run(async () => { await window.goodbuddy.knowledge.externalInstancesSave(draft); setDraft(undefined) }, t('external.saved')) }}>
|
||||
<fieldset disabled={busy} className="external-knowledge__fieldset">
|
||||
<div className="external-knowledge__columns">
|
||||
<Field label={t('fields.name')}><input autoFocus required value={draft.name} onChange={event => change({ name: event.currentTarget.value })} /></Field>
|
||||
<Field label={t('external.provider')}><select disabled={!!draft.id && instances.some(item => item.id === draft.id && item.bindingCount > 0)} value={draft.provider} onChange={event => change({ provider: event.currentTarget.value as ExternalKnowledgeProvider })}>{Object.entries(externalProviderNames).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
|
||||
</div>
|
||||
<Field label={t('external.address')}><input type="url" required value={draft.baseUrl} aria-describedby={/^http:\/\//i.test(draft.baseUrl) ? `${keyId}-transport` : undefined} onChange={event => change({ baseUrl: event.currentTarget.value })} /></Field>
|
||||
{/^http:\/\//i.test(draft.baseUrl) && <p className="external-knowledge__help" id={`${keyId}-transport`}>{t('external.transportNote')}</p>}
|
||||
<fieldset className="external-knowledge__group">
|
||||
<legend>{t('external.authentication')}</legend>
|
||||
{draft.id && <Field label={t('external.credentialAction')}><select value={draft.credential.action} onChange={event => { setShowKey(false); change({ credential: event.currentTarget.value === 'replace' ? { action: 'replace', value: '' } : { action: event.currentTarget.value as 'keep' | 'clear' } }) }}>
|
||||
{draft.id && <option value="keep">{t('external.keep')}</option>}<option value="replace">{t('external.replace')}</option>{draft.id && <option value="clear">{t('external.clear')}</option>}
|
||||
</select></Field>}
|
||||
{draft.credential.action === 'replace' && <div className="field">
|
||||
<span><label htmlFor={keyId}>{t('external.credential')}</label></span>
|
||||
<div className="external-knowledge__password"><input id={keyId} type={showKey ? 'text' : 'password'} autoComplete="new-password" required aria-describedby={`${keyId}-note`} value={draft.credential.value} onChange={event => change({ credential: { action: 'replace', value: event.currentTarget.value } })} />
|
||||
<button type="button" className="icon-button" aria-label={t(showKey ? 'external.hideKey' : 'external.showKey')} title={t(showKey ? 'external.hideKey' : 'external.showKey')} aria-pressed={showKey} onClick={() => setShowKey(value => !value)}>{showKey ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
</div>
|
||||
</div>}
|
||||
<p className="external-knowledge__help" id={`${keyId}-note`}>{t('external.credentialNote')}</p>
|
||||
{draft.credential.action === 'clear' && <label><input type="checkbox" checked={clearConfirmed} onChange={event => setClearConfirmed(event.currentTarget.checked)} />{t('external.clearConfirm')}</label>}
|
||||
</fieldset>
|
||||
<div className="external-knowledge__fields">
|
||||
<Switch label={t('external.enabled')} checked={draft.enabled} onChange={value => change({ enabled: value })} />
|
||||
<p className="external-knowledge__help">{t('external.enabledHelp')}</p>
|
||||
</div>
|
||||
<details className="external-knowledge__help"><summary>{t('external.connectionHelp')}</summary><p>{t('external.saveThenTest')}</p></details>
|
||||
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} /><button type="button" className="secondary-button" disabled={busy} onClick={() => setDraft(undefined)}>{t('actions.cancel')}</button><button type="submit" className="primary-button" disabled={busy || (draft.credential.action === 'clear' && !clearConfirmed) || !URL.canParse(draft.baseUrl) || !externalKnowledgeInstanceSaveInputSchema.safeParse(draft).success}>{t(busy ? 'actions.saving' : 'external.saveInstance')}</button></footer>
|
||||
</fieldset>
|
||||
</form> : deleting ? <section className="external-knowledge__fields" aria-labelledby="external-instance-title">
|
||||
<p>{t('external.deleteInstanceConfirm', { count: libraries.filter(item => item.external?.instanceId === deleting.id).length })}</p>
|
||||
<ul>{libraries.filter(item => item.external?.instanceId === deleting.id).map(item => <li key={item.id}>{item.name}</li>)}</ul>
|
||||
{deleteError && <p role="alert" id="external-delete-error">{deleteError}</p>}
|
||||
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} /><button type="button" className="secondary-button" autoFocus disabled={busy} onClick={() => setDeleting(undefined)}>{t('actions.cancel')}</button><button type="button" className="danger-button" disabled={busy} aria-describedby={deleteError ? 'external-delete-error' : undefined} onClick={() => {
|
||||
setBusy(true); setDeleteError(undefined)
|
||||
void (async () => {
|
||||
try {
|
||||
for (const item of libraries.filter(item => item.external?.instanceId === deleting.id)) await window.goodbuddy.knowledge.deleteLibrary(item.id)
|
||||
await window.goodbuddy.knowledge.externalInstancesDelete({ instanceId: deleting.id })
|
||||
setDeleting(undefined); notify({ tone: 'success', message: t('external.removed') })
|
||||
} catch (error) { setDeleteError(errorMessage(error)) }
|
||||
finally {
|
||||
// A failed later deletion must still refresh bindings already removed.
|
||||
try { await onChanged() } catch (error) { notify({ tone: 'error', message: errorMessage(error) }) }
|
||||
setBusy(false)
|
||||
}
|
||||
})()
|
||||
}}>{t('external.deleteInstance')}</button></footer>
|
||||
</section> : <>
|
||||
<button type="button" className="primary-button" disabled={busy} onClick={() => edit()}>{t('external.addInstance')}</button>
|
||||
{!instances.length && <EmptyState title={t('external.noInstances')} description={t('external.noInstancesHelp')} icon={<Database size={24} />} />}
|
||||
<ul className="external-knowledge__instances">{instances.map(instance => <li key={instance.id}>
|
||||
<div><strong>{instance.name}</strong><p>{externalProviderNames[instance.provider]} · {new URL(instance.baseUrl).origin}</p><small>{instance.enabled ? t(`external.probe.${instance.probeStatus}`) : t('external.states.instance-disabled')} · {t(`external.credentials.${instance.credentialStatus}`)} · {t('external.boundCount', { count: instance.bindingCount })}</small>{instance.lastTestedAt && <small>{t('external.lastTest')}: <ExternalTime value={instance.lastTestedAt} /></small>}</div>
|
||||
<div className="external-knowledge__actions"><Switch label={`${t('external.enabled')}: ${instance.name}`} checked={instance.enabled} disabled={busy} onChange={enabled => void run(() => window.goodbuddy.knowledge.externalInstancesSetEnabled({ instanceId: instance.id, enabled }))} />
|
||||
<button type="button" className="secondary-button" disabled={busy || !instance.enabled || instance.credentialStatus !== 'configured'} onClick={() => void run(async () => { const value = await window.goodbuddy.knowledge.externalInstancesTest({ instanceId: instance.id }); notify({ tone: value.probeStatus === 'auth-failed' || value.probeStatus === 'failed' || value.probeStatus === 'unreachable' ? 'error' : 'info', message: `${instance.name}: ${t(`external.probe.${value.probeStatus}`)}` }) })}>{t('external.testConnection')}</button>
|
||||
<button type="button" className="secondary-button" disabled={busy} onClick={() => edit(instance)}>{t('actions.edit')}</button><button type="button" className="danger-ghost" disabled={busy} onClick={() => setDeleting(instance)}>{t('actions.remove')}</button>
|
||||
</div>
|
||||
</li>)}</ul>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
</div>, document.body)
|
||||
}
|
||||
|
||||
function ExternalRetrievalTest({ library, instance }: { library: KnowledgeLibrary; instance?: ExternalKnowledgeInstanceSummary }): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [query, setQuery] = useState('')
|
||||
const [result, setResult] = useState<ExternalKnowledgeTestResult>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const generation = useRef(0)
|
||||
const errorId = useId()
|
||||
useEffect(() => () => { generation.current++ }, [])
|
||||
const binding = library.external!
|
||||
const available = instance?.enabled && instance.credentialStatus === 'configured'
|
||||
return <form className="external-knowledge__fields" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
if (busy || !available || !query.trim()) return
|
||||
const current = ++generation.current
|
||||
setBusy(true); setError(undefined); setResult(undefined)
|
||||
void window.goodbuddy.knowledge.externalRetrievalTest({ instanceId: binding.instanceId, remoteKnowledgeBaseId: binding.remoteKnowledgeBaseId, commonConfig: binding.commonConfig, providerConfig: binding.providerConfig, testQuery: query }).then(value => { if (current === generation.current) setResult(value) }).catch(reason => { if (current === generation.current) setError(errorMessage(reason)) }).finally(() => { if (current === generation.current) setBusy(false) })
|
||||
}}>
|
||||
<p>{externalProviderNames[binding.provider]} · {instance?.name} · {binding.remoteName}</p>
|
||||
<p>{t('external.queryNote')}</p>
|
||||
{!available && <p role="status">{t(`external.states.${externalLibraryStatus(library, instance ? [instance] : [])}`)}</p>}
|
||||
<Field label={t('external.query')}><textarea required disabled={busy} maxLength={4000} value={query} aria-describedby={error ? errorId : undefined} onChange={event => { generation.current++; setError(undefined); setResult(undefined); setQuery(event.currentTarget.value) }} /></Field>
|
||||
<button type="submit" className="secondary-button" disabled={busy || !available || !query.trim()}>{t(busy ? 'external.testing' : 'external.test')}</button>
|
||||
{error && <p role="alert" id={errorId}>{error}</p>}
|
||||
{result && <TestResults result={result} />}
|
||||
</form>
|
||||
}
|
||||
|
||||
export function ExternalLibraryDetail({ library, instances, libraries, onChanged, notify, onManage, onUseInChat }: {
|
||||
library: KnowledgeLibrary; instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; onChanged: Changed; notify: Notify; onManage: () => void; onUseInChat: (id: string) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [tab, setTab] = useState<'overview' | 'config' | 'test'>('overview')
|
||||
const [configVisited, setConfigVisited] = useState(false)
|
||||
const selectTab = (value: typeof tab): void => { if (value === 'config') setConfigVisited(true); setTab(value) }
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [remoteDetail, setRemoteDetail] = useState<ExternalKnowledgeCatalogItem>()
|
||||
const [detailError, setDetailError] = useState<string>()
|
||||
const detailGeneration = useRef(0)
|
||||
useEffect(() => () => { detailGeneration.current++ }, [])
|
||||
const binding = library.external!
|
||||
const instance = instances.find(item => item.id === binding.instanceId)
|
||||
return <div className="external-knowledge">
|
||||
<header><h2>{library.name}</h2><p>{externalProviderNames[binding.provider]} · {instance?.name} · {binding.remoteName}</p><ScopeBadge scope={{ kind: 'global' }} /></header>
|
||||
<PageTabs idPrefix={`external-${library.id}`} ariaLabel={library.name} value={tab} onChange={selectTab} tabs={[{ id: 'overview', label: t('external.overview') }, { id: 'config', label: t('external.config') }, { id: 'test', label: t('external.test') }]} />
|
||||
{tab !== 'config' && <div role="tabpanel" id={`external-${library.id}-panel-${tab}`} aria-labelledby={`external-${library.id}-tab-${tab}`}>
|
||||
{tab === 'overview' ? <section className="external-knowledge__fields">
|
||||
<dl><dt>{t('external.remoteId')}</dt><dd className="external-knowledge__identifier">{binding.remoteKnowledgeBaseId}</dd><dt>{t('external.status')}</dt><dd>{t(`external.states.${externalLibraryStatus(library, instances)}`)}</dd><dt>{t('external.lastTest')}</dt><dd>{binding.lastVerifiedAt ? <ExternalTime value={binding.lastVerifiedAt} /> : t('external.never')}</dd></dl>
|
||||
<p>{t('external.remoteData')}</p><p>{t('external.localOnly')}</p>
|
||||
<button type="button" className="secondary-button" disabled={busy} onClick={() => {
|
||||
const current = ++detailGeneration.current
|
||||
setBusy(true); setDetailError(undefined)
|
||||
void window.goodbuddy.knowledge.externalCatalogGet({ instanceId: binding.instanceId, remoteKnowledgeBaseId: binding.remoteKnowledgeBaseId }).then(value => { if (current === detailGeneration.current) setRemoteDetail(value) }).catch(error => { if (current === detailGeneration.current) setDetailError(errorMessage(error)) }).finally(() => { if (current === detailGeneration.current) setBusy(false) })
|
||||
}}>{t('external.refreshDetail')}</button>
|
||||
{detailError && <p role="alert">{detailError}</p>}
|
||||
{remoteDetail && <p role="status">{remoteDetail.name} · {remoteDetail.description}</p>}
|
||||
<div className="external-knowledge__actions"><button type="button" className="primary-button" disabled={externalLibraryStatus(library, instances) !== 'ready'} onClick={() => onUseInChat(library.id)}>{t('actions.useInChat')}</button><button type="button" className="secondary-button" onClick={() => selectTab('config')}>{t('external.rebind')}</button><button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button><button type="button" className="danger-ghost" onClick={() => setRemoving(true)}>{t('external.remove')}</button></div>
|
||||
{removing && <div className="external-knowledge__group"><p>{t('external.removeBindingConfirm')}</p><div className="external-knowledge__actions"><button type="button" className="secondary-button" autoFocus disabled={busy} onClick={() => setRemoving(false)}>{t('actions.cancel')}</button><button type="button" className="danger-button" disabled={busy} onClick={() => { setBusy(true); void window.goodbuddy.knowledge.deleteLibrary(library.id).then(async () => { await onChanged(); notify({ tone: 'success', message: t('external.removed') }) }).catch(error => notify({ tone: 'error', message: errorMessage(error) })).finally(() => setBusy(false)) }}>{t('external.remove')}</button></div></div>}
|
||||
</section> : <ExternalRetrievalTest key={JSON.stringify([binding, instance])} library={library} instance={instance} />}
|
||||
</div>}
|
||||
{configVisited && <div hidden={tab !== 'config'} role="tabpanel" id={`external-${library.id}-panel-config`} aria-labelledby={`external-${library.id}-tab-config`}><ExternalBindingForm provider={binding.provider} instances={instances} libraries={libraries} library={library} onCancel={() => { setConfigVisited(false); setTab('overview') }} onChanged={onChanged} notify={notify} onManage={onManage} /></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -30,6 +30,14 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('KnowledgeCitationDialog', () => {
|
||||
it('reads historical external snippets and typed scores without local source actions', () => {
|
||||
const onOpenSource = vi.fn()
|
||||
render(<KnowledgeCitationDialog reference={{ ...reference, documentId: undefined, chunkId: undefined, external: { kind: 'external', provider: 'fastgpt', instanceId: 'removed-instance', remoteKnowledgeBaseId: 'remote-library', providerScores: [{ type: 'embedding', value: 0.8, index: 2 }] } }} onClose={vi.fn()} onOpenSource={onOpenSource} />)
|
||||
expect(screen.getByText(reference.snippet)).toBeInTheDocument()
|
||||
expect(screen.getByText('embedding [2]: 0.8')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '打开来源' })).not.toBeInTheDocument()
|
||||
expect(onOpenSource).not.toHaveBeenCalled()
|
||||
})
|
||||
it('shows matched and surrounding context and restores close behavior', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onOpenSource = vi.fn(async () => undefined)
|
||||
|
||||
@@ -33,6 +33,7 @@ export function KnowledgeCitationDialog({
|
||||
onOpenSource
|
||||
}: KnowledgeCitationDialogProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const { t: tk } = useTranslation('knowledge')
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
@@ -43,6 +44,7 @@ export function KnowledgeCitationDialog({
|
||||
}, [])
|
||||
|
||||
const openSource = async (): Promise<void> => {
|
||||
if (reference.external || !reference.documentId) return
|
||||
setOpening(true)
|
||||
setOpenError(undefined)
|
||||
try {
|
||||
@@ -84,7 +86,7 @@ export function KnowledgeCitationDialog({
|
||||
<h2 id="knowledge-citation-dialog-title">
|
||||
{t('chat.citations.contextTitle')}
|
||||
</h2>
|
||||
<p>{t('chat.citations.contextDescription')}</p>
|
||||
<p>{reference.external ? tk('external.snapshot') : t('chat.citations.contextDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('chat.citations.closeContext')}
|
||||
@@ -99,13 +101,21 @@ export function KnowledgeCitationDialog({
|
||||
</header>
|
||||
|
||||
<dl className="knowledge-citation-dialog__metadata">
|
||||
{reference.external && <>
|
||||
<div><dt>{tk('external.provider')}</dt><dd>{({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[reference.external.provider]}</dd></div>
|
||||
<div><dt>{tk('external.instance')}</dt><dd>{reference.external.instanceId}</dd></div>
|
||||
<div><dt>{tk('external.remoteId')}</dt><dd>{reference.external.remoteKnowledgeBaseId}</dd></div>
|
||||
{reference.external.remoteDocumentId && <div><dt>{tk('external.documentId')}</dt><dd>{reference.external.remoteDocumentId}</dd></div>}
|
||||
{reference.external.remoteChunkId && <div><dt>{tk('external.chunkId')}</dt><dd>{reference.external.remoteChunkId}</dd></div>}
|
||||
<div><dt>{tk('external.providerScore')}</dt>{reference.external.providerScore !== undefined && <dd>{reference.external.providerScore}</dd>}{reference.external.providerScores?.map((score, index) => <dd key={index}>{score.type}{score.index === undefined ? '' : ` [${score.index}]`}: {score.value}</dd>)}</div>
|
||||
</>}
|
||||
<div>
|
||||
<dt>{reference.documentName}</dt>
|
||||
<dd>{context?.sourceName ?? reference.sourceName}</dd>
|
||||
</div>
|
||||
{(context?.locator ?? reference.locator) && (
|
||||
{(context?.locator ?? reference.external?.location ?? reference.locator) && (
|
||||
<div>
|
||||
<dt>{context?.locator ?? reference.locator}</dt>
|
||||
<dt>{context?.locator ?? reference.external?.location ?? reference.locator}</dt>
|
||||
{reference.score !== undefined && (
|
||||
<dd>
|
||||
{t('chat.citations.score', {
|
||||
@@ -117,7 +127,7 @@ export function KnowledgeCitationDialog({
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{loading ? (
|
||||
{reference.external ? <div className="knowledge-citation-dialog__content"><section><h3>{t('chat.citations.matchedChunk')}</h3><p>{reference.snippet}</p></section></div> : loading ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="knowledge-citation-dialog__state"
|
||||
@@ -175,7 +185,7 @@ export function KnowledgeCitationDialog({
|
||||
>
|
||||
{t('chat.citations.closeContext')}
|
||||
</button>
|
||||
<button
|
||||
{!reference.external && reference.documentId && <button
|
||||
className="primary-button"
|
||||
disabled={opening}
|
||||
onClick={() => void openSource()}
|
||||
@@ -187,7 +197,7 @@ export function KnowledgeCitationDialog({
|
||||
<ExternalLink aria-hidden="true" size={15} />
|
||||
)}
|
||||
{t('chat.citations.openSource')}
|
||||
</button>
|
||||
</button>}
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
|
||||
@@ -40,8 +40,9 @@ export type KnowledgeRetrievalChannelDetail = {
|
||||
}
|
||||
|
||||
export type KnowledgeRetrievalWorkbenchResult = {
|
||||
chunkId: string
|
||||
documentId: string
|
||||
external?: import('../../shared/external-knowledge-contracts').ExternalKnowledgeLocator
|
||||
chunkId?: string
|
||||
documentId?: string
|
||||
rank: number
|
||||
documentName: string
|
||||
sourceName: string
|
||||
@@ -1013,7 +1014,7 @@ export function KnowledgeRetrievalWorkbench({
|
||||
{results.map((result) => (
|
||||
<li
|
||||
className="knowledge-retrieval-result"
|
||||
key={result.chunkId}
|
||||
key={result.chunkId ?? `external-${result.rank}`}
|
||||
>
|
||||
<article
|
||||
aria-label={t('retrieval.results.resultAriaLabel', {
|
||||
@@ -1094,7 +1095,7 @@ export function KnowledgeRetrievalWorkbench({
|
||||
<p>{result.contextText}</p>
|
||||
</details>
|
||||
)}
|
||||
<footer>
|
||||
{!result.external && result.documentId && result.chunkId && <footer>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => onViewContext(result)}
|
||||
@@ -1111,7 +1112,7 @@ export function KnowledgeRetrievalWorkbench({
|
||||
<ExternalLink aria-hidden="true" size={14} />
|
||||
{t('retrieval.actions.openSource')}
|
||||
</button>
|
||||
</footer>
|
||||
</footer>}
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -262,6 +262,25 @@ function createProps(
|
||||
}
|
||||
|
||||
describe('KnowledgeWorkspace', () => {
|
||||
it('searches external names and instances and filters the unified library list', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const external = { ...library, id: 'external-1', name: 'Company handbook', external: { knowledgeBaseId: 'external-1', instanceId: 'instance-1', provider: 'dify' as const, remoteKnowledgeBaseId: 'remote-1', remoteName: 'Remote policies', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: { provider: 'dify' as const, useDatasetDefaults: true as const }, lastVerifiedAt: '2026-09-12' } }
|
||||
const props = createProps({ libraries: [library, external], externalInstances: [{ id: 'instance-1', name: 'Enterprise service', provider: 'dify', baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured', probeStatus: 'healthy', bindingCount: 1 }] })
|
||||
render(<KnowledgeWorkspace {...props} />)
|
||||
const nav = screen.getByRole('navigation', { name: 'Library list' })
|
||||
for (const query of ['Remote policies', 'Enterprise service', 'dify']) {
|
||||
fireEvent.change(screen.getByLabelText('Search libraries'), { target: { value: query } })
|
||||
expect(within(nav).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(nav).getByRole('button', { name: /Company handbook/ })).toBeInTheDocument()
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText('Library source filter'), { target: { value: 'local' } })
|
||||
expect(within(nav).getByText('No matching results')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
|
||||
expect(within(nav).getAllByRole('button')).toHaveLength(2)
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Company handbook/ }))
|
||||
expect(props.onSelectLibrary).toHaveBeenCalledWith(external.id)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
@@ -2022,7 +2041,8 @@ describe('KnowledgeWorkspace', () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '新建知识库' })
|
||||
).not.toBeInTheDocument()
|
||||
).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '管理外部实例' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a retryable load error instead of the first-library empty state', () => {
|
||||
|
||||
@@ -94,6 +94,10 @@ import {
|
||||
} from './KnowledgeRetrievalWorkbench'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection'
|
||||
import { ExternalBindingForm, ExternalInstanceManager, ExternalLibraryDetail, externalProviderNames, externalLibraryStatus } from './ExternalKnowledge'
|
||||
import type { ExternalKnowledgeInstanceSummary, ExternalKnowledgeProvider } from '../../shared/external-knowledge-contracts'
|
||||
import type { KnowledgeSnapshot } from '../../shared/contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
|
||||
type KnowledgeGraphChartModule = typeof import('./KnowledgeGraphChart')
|
||||
|
||||
@@ -226,6 +230,9 @@ export type KnowledgeRelationInput = {
|
||||
}
|
||||
|
||||
export type KnowledgeWorkspaceProps = {
|
||||
externalInstances?: ExternalKnowledgeInstanceSummary[]
|
||||
onExternalChanged?: (snapshot?: KnowledgeSnapshot, createdId?: string) => void | Promise<void>
|
||||
notify?: (input: AppNotificationInput) => void
|
||||
libraries: readonly KnowledgeLibrary[]
|
||||
selectedLibraryId?: string
|
||||
sources: readonly KnowledgeSource[]
|
||||
@@ -455,8 +462,9 @@ function toWorkbenchResponse(
|
||||
rerank: response.diagnostics.rerank
|
||||
},
|
||||
results: response.results.map((result) => {
|
||||
const context = contextByChunkId.get(result.chunkId)
|
||||
const context = result.chunkId ? contextByChunkId.get(result.chunkId) : undefined
|
||||
return {
|
||||
external: result.external,
|
||||
chunkId: result.chunkId,
|
||||
documentId: result.documentId,
|
||||
rank: result.rank,
|
||||
@@ -4302,6 +4310,9 @@ function GraphView({
|
||||
}
|
||||
|
||||
export function KnowledgeWorkspace({
|
||||
externalInstances = [],
|
||||
onExternalChanged = () => {},
|
||||
notify = () => {},
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
sources,
|
||||
@@ -4354,6 +4365,15 @@ export function KnowledgeWorkspace({
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [creationType, setCreationType] = useState<'local' | ExternalKnowledgeProvider>('local')
|
||||
const [managingExternal, setManagingExternal] = useState(false)
|
||||
const [librarySearch, setLibrarySearch] = useState('')
|
||||
const [librarySource, setLibrarySource] = useState('all')
|
||||
const filteredLibraries = libraries.filter(library => {
|
||||
if (librarySource !== 'all' && (library.external?.provider ?? 'local') !== librarySource) return false
|
||||
const instanceName = externalInstances.find(instance => instance.id === library.external?.instanceId)?.name ?? ''
|
||||
return [library.name, library.external?.remoteName, library.external?.provider ?? t('external.local'), instanceName].join(' ').toLocaleLowerCase(locale).includes(librarySearch.trim().toLocaleLowerCase(locale))
|
||||
})
|
||||
const [mobileListOpen, setMobileListOpen] = useState(false)
|
||||
const [tab, setTab] = useState<WorkspaceTab>('documents')
|
||||
const [graphTab, setGraphTab] =
|
||||
@@ -4505,9 +4525,11 @@ export function KnowledgeWorkspace({
|
||||
|
||||
return (
|
||||
<div className="knowledge-page">
|
||||
{managingExternal && <ExternalInstanceManager instances={externalInstances} libraries={libraries} onClose={() => setManagingExternal(false)} onChanged={onExternalChanged} notify={notify} />}
|
||||
<PageHeader
|
||||
actions={
|
||||
libraries.length > 0 ? (
|
||||
<>
|
||||
<button type="button" className="secondary-button" onClick={() => setManagingExternal(true)}><Database size={16} />{t('external.manage')}</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading}
|
||||
@@ -4520,7 +4542,7 @@ export function KnowledgeWorkspace({
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
{t('actions.newLibrary')}
|
||||
</button>
|
||||
) : undefined
|
||||
</>
|
||||
}
|
||||
description={t('page.description')}
|
||||
headingId="knowledge-workspace-title"
|
||||
@@ -4542,22 +4564,27 @@ export function KnowledgeWorkspace({
|
||||
<strong>{t('workspace.libraryList')}</strong>
|
||||
</span>
|
||||
<small>
|
||||
{formatNumber(libraries.length, locale)}
|
||||
{formatNumber(filteredLibraries.length, locale)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="external-knowledge__filters">
|
||||
<label className="knowledge-field">{t('external.searchLibraries')}<input type="search" value={librarySearch} onChange={event => setLibrarySearch(event.currentTarget.value)} /></label>
|
||||
<label className="knowledge-field">{t('external.sourceFilter')}<select value={librarySource} onChange={event => setLibrarySource(event.currentTarget.value)}><option value="all">{t('external.allSources')}</option><option value="local">{t('external.local')}</option>{Object.entries(externalProviderNames).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
</div>
|
||||
<nav
|
||||
aria-label={t('workspace.libraryList')}
|
||||
className="knowledge-workspace__library-nav"
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
{(loading || loadError) && libraries.length === 0 ? null : filteredLibraries.length === 0 ? (
|
||||
<div className="knowledge-workspace__library-empty">
|
||||
{t('workspace.libraryListEmpty')}
|
||||
{t(libraries.length ? 'external.noResults' : 'workspace.libraryListEmpty')}
|
||||
{(librarySearch || librarySource !== 'all') && <button type="button" className="secondary-button" onClick={() => { setLibrarySearch(''); setLibrarySource('all') }}>{t('external.clearFilters')}</button>}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="knowledge-workspace__library-list">
|
||||
{libraries.map((library) => {
|
||||
{filteredLibraries.map((library) => {
|
||||
const selected = library.id === selectedLibrary?.id
|
||||
const libraryMeta = t('workspace.libraryMeta', {
|
||||
const libraryMeta = library.external ? `${externalProviderNames[library.external.provider]} · ${externalInstances.find(instance => instance.id === library.external?.instanceId)?.name ?? library.external.instanceId} · ${t(`external.states.${externalLibraryStatus(library, externalInstances)}`)}` : t('workspace.libraryMeta', {
|
||||
ready: formatNumber(
|
||||
library.indexedDocumentCount,
|
||||
locale
|
||||
@@ -4658,27 +4685,24 @@ export function KnowledgeWorkspace({
|
||||
title={t('errors.loadTitle')}
|
||||
/>
|
||||
) : creating ? (
|
||||
<>
|
||||
<SegmentedControl ariaLabel={t('external.type')} value={creationType} onChange={setCreationType} options={[{ value: 'local', label: t('external.local') }, ...Object.entries(externalProviderNames).map(([value, label]) => ({ value: value as ExternalKnowledgeProvider, label }))]} />
|
||||
{creationType === 'local' ?
|
||||
<CreateLibraryWizard
|
||||
onCancel={() => setCreating(false)}
|
||||
onCreate={onCreateLibrary}
|
||||
/>
|
||||
: <ExternalBindingForm key={creationType} provider={creationType} instances={externalInstances} libraries={libraries} onCancel={() => setCreating(false)} onChanged={onExternalChanged} notify={notify} onManage={() => setManagingExternal(true)} />}
|
||||
</>
|
||||
) : !selectedLibrary ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setCreating(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
{t('actions.newLibrary')}
|
||||
</button>
|
||||
}
|
||||
description={t('empty.description')}
|
||||
icon={<BookOpen size={34} />}
|
||||
level="page"
|
||||
title={t('empty.title')}
|
||||
/>
|
||||
) : selectedLibrary.external ? (
|
||||
<ExternalLibraryDetail key={selectedLibrary.id} library={selectedLibrary} instances={externalInstances} libraries={libraries} onChanged={onExternalChanged} notify={notify} onManage={() => setManagingExternal(true)} onUseInChat={onUseInChat} />
|
||||
) : (
|
||||
<>
|
||||
<header
|
||||
@@ -4948,6 +4972,7 @@ export function KnowledgeWorkspace({
|
||||
libraryName={selectedLibrary.name}
|
||||
onClose={() => setRetrievalOpen(false)}
|
||||
onOpenSource={(result) => {
|
||||
if (result.external || !result.documentId || !result.chunkId) return
|
||||
void Promise.resolve(
|
||||
onOpenReferenceSource({
|
||||
knowledgeBaseId: selectedLibrary.id,
|
||||
@@ -5007,6 +5032,7 @@ export function KnowledgeWorkspace({
|
||||
}
|
||||
}}
|
||||
onViewContext={(result) => {
|
||||
if (result.external || !result.documentId || !result.chunkId) return
|
||||
const document = libraryDocuments.find(
|
||||
(item) => item.id === result.documentId
|
||||
)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createInstance } from 'i18next'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ProjectActivity, ProjectActivityCounts } from './ProjectActivity'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
|
||||
const i18n = createInstance()
|
||||
void i18n.init({
|
||||
lng: 'en',
|
||||
initImmediate: false,
|
||||
resources: { en: { workspace: { projectActivity: {
|
||||
title: 'All project activity', close: 'Close activity',
|
||||
attentionCount: '{{count}} need attention', runningCount: '{{count}} running',
|
||||
groups: { attention: 'Needs attention', running: 'Running' },
|
||||
status: { running: 'Running', approval: 'Waiting for approval', question: 'Waiting for your answer', attention: 'Needs attention' }
|
||||
} } } }
|
||||
})
|
||||
|
||||
const activities: ConversationActivity[] = [
|
||||
{ conversationId: 'running', projectId: 'local', projectName: 'Local', title: 'Build', status: 'running' },
|
||||
{ conversationId: 'question', projectId: 'remote', projectName: 'Remote', title: 'Choose target', status: 'question' },
|
||||
{ conversationId: 'approval', projectId: 'remote', projectName: 'Remote', title: 'Approve command', status: 'approval' },
|
||||
{ conversationId: 'unloaded', projectName: 'Channel', title: 'Background task', status: 'attention' }
|
||||
]
|
||||
|
||||
function view(rows = activities, onOpenConversation = vi.fn()): React.JSX.Element {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<div className="app-shell">
|
||||
<button type="button">Destination</button>
|
||||
<ProjectActivity activities={rows} onOpenConversation={onOpenConversation} />
|
||||
</div>
|
||||
</I18nextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ProjectActivity', () => {
|
||||
it('hides idle activity and zero counts', () => {
|
||||
render(view([]))
|
||||
expect(screen.queryByRole('button', { name: /All project activity/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
const { container } = render(
|
||||
<I18nextProvider i18n={i18n}><ProjectActivityCounts attention={0} running={0} /></I18nextProvider>
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('shows global counts and attention-first groups with precise labels', () => {
|
||||
render(view())
|
||||
fireEvent.click(screen.getByRole('button', { name: /All project activity 3 need attention 1 running/ }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'All project activity' })
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
expect(within(dialog).getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent))
|
||||
.toEqual(['Needs attention', 'Running'])
|
||||
expect(within(dialog).getByRole('button', { name: 'Remote Choose target Waiting for your answer' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: 'Remote Approve command Waiting for approval' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: 'Channel Background task Needs attention' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: 'Local Build Running' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('isolates the background, traps Tab both ways, and restores focus on Escape', () => {
|
||||
const { container } = render(view())
|
||||
const trigger = screen.getByRole('button', { name: /All project activity/ })
|
||||
fireEvent.click(trigger)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const close = within(dialog).getByRole('button', { name: 'Close activity' })
|
||||
const last = within(dialog).getByRole('button', { name: 'Local Build Running' })
|
||||
expect(close).toHaveFocus()
|
||||
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(true)
|
||||
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(close).toHaveFocus()
|
||||
fireEvent.keyDown(close, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
|
||||
})
|
||||
|
||||
it('navigates directly to an unloaded conversation without stealing destination focus', () => {
|
||||
const onOpen = vi.fn(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
|
||||
screen.getByRole('button', { name: 'Destination' }).focus()
|
||||
})
|
||||
render(view(activities, onOpen))
|
||||
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Channel Background task Needs attention' }))
|
||||
expect(onOpen).toHaveBeenCalledExactlyOnceWith('unloaded')
|
||||
expect(screen.getByRole('button', { name: 'Destination' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('updates live rows without refocusing and stays closed after becoming idle', () => {
|
||||
const { rerender } = render(view())
|
||||
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
|
||||
const row = screen.getByRole('button', { name: 'Remote Choose target Waiting for your answer' })
|
||||
row.focus()
|
||||
rerender(view(activities.filter((activity) => activity.status !== 'running')))
|
||||
expect(row).toHaveFocus()
|
||||
expect(screen.queryByRole('region', { name: 'Running' })).not.toBeInTheDocument()
|
||||
rerender(view([]))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
|
||||
rerender(view())
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('restores trigger focus when dismissed with the close button', () => {
|
||||
render(view())
|
||||
const trigger = screen.getByRole('button', { name: /All project activity/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close activity' }))
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('still handles Escape when a background update removes the focused row', () => {
|
||||
const { rerender } = render(view())
|
||||
const trigger = screen.getByRole('button', { name: /All project activity/ })
|
||||
fireEvent.click(trigger)
|
||||
screen.getByRole('button', { name: 'Local Build Running' }).focus()
|
||||
rerender(view(activities.filter((activity) => activity.status !== 'running')))
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { ChevronRight, CircleAlert, LoaderCircle, X } from 'lucide-react'
|
||||
import { useEffect, useEffectEvent, useId, useRef, useState } from 'react'
|
||||
import { createPortal, flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import './project-activity.css'
|
||||
|
||||
export type ProjectActivityProps = {
|
||||
activities: ConversationActivity[]
|
||||
onOpenConversation: (id: string) => void
|
||||
}
|
||||
|
||||
export type ProjectActivityCountsProps = { running: number; attention: number }
|
||||
|
||||
export function ProjectActivityCounts({
|
||||
running,
|
||||
attention
|
||||
}: ProjectActivityCountsProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation('workspace')
|
||||
if (!running && !attention) return null
|
||||
return (
|
||||
<span className="project-activity__counts">
|
||||
{attention > 0 && (
|
||||
<span className="project-activity__attention">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
{t('projectActivity.attentionCount', { count: attention })}
|
||||
</span>
|
||||
)}
|
||||
{' '}
|
||||
{running > 0 && (
|
||||
<span className="project-activity__running">
|
||||
<LoaderCircle aria-hidden="true" size={13} />
|
||||
{t('projectActivity.runningCount', { count: running })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityDialog({
|
||||
activities,
|
||||
onOpenConversation,
|
||||
onClose
|
||||
}: ProjectActivityProps & { onClose: () => void }): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const titleId = useId()
|
||||
const handleKeyDown = useEffectEvent((event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
} else {
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
const releaseFocus = activateModalFocus(
|
||||
() => dialogRef.current?.querySelector<HTMLButtonElement>('button') ?? null
|
||||
)
|
||||
const onKeyDown = (event: KeyboardEvent): void => handleKeyDown(event)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
releaseFocus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="project-activity__backdrop"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<section
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className="project-activity__dialog"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
>
|
||||
<header className="project-activity__header">
|
||||
<h2 id={titleId}>{t('projectActivity.title')}</h2>
|
||||
<button
|
||||
aria-label={t('projectActivity.close')}
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
title={t('projectActivity.close')}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="project-activity__content">
|
||||
{(['attention', 'running'] as const).map((group) => {
|
||||
const rows = activities.filter((activity) =>
|
||||
group === 'running' ? activity.status === 'running' : activity.status !== 'running'
|
||||
)
|
||||
if (!rows.length) return null
|
||||
return (
|
||||
<section aria-labelledby={`${titleId}-${group}`} key={group}>
|
||||
<h3 id={`${titleId}-${group}`}>{t(`projectActivity.groups.${group}`)}</h3>
|
||||
<ul>
|
||||
{rows.map((activity) => (
|
||||
<li key={activity.conversationId}>
|
||||
<button
|
||||
className="project-activity__row"
|
||||
onClick={() => {
|
||||
// Release modal isolation and restore focus before navigation owns it.
|
||||
flushSync(onClose)
|
||||
onOpenConversation(activity.conversationId)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="project-activity__identity">
|
||||
<small>{activity.projectName}</small>{' '}
|
||||
<span>{activity.title}</span>
|
||||
</span>{' '}
|
||||
<span className={`project-activity__${group}`}>
|
||||
{t(`projectActivity.status.${activity.status}`)}
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectActivity({
|
||||
activities,
|
||||
onOpenConversation
|
||||
}: ProjectActivityProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation('workspace')
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!activities.length) {
|
||||
if (open) setOpen(false)
|
||||
return null
|
||||
}
|
||||
const running = activities.filter((activity) => activity.status === 'running').length
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className="project-activity__summary"
|
||||
onClick={(event) => {
|
||||
event.currentTarget.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{t('projectActivity.title')}</span>{' '}
|
||||
<ProjectActivityCounts attention={activities.length - running} running={running} />
|
||||
<ChevronRight aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<ActivityDialog
|
||||
activities={activities}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -75,6 +75,7 @@ const project: AssistantProject = {
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -308,6 +309,50 @@ function installRemoteApi(
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProjectSwitcher project activity integration', () => {
|
||||
it('keeps per-project title counts and menu ordering stable across recovery and Host states', async () => {
|
||||
const api = installRemoteApi({ connectionState: 'connecting' })
|
||||
const remote: AssistantProject = {
|
||||
...project, id: remoteProjectId, name: 'Remote activity',
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
|
||||
}
|
||||
const idle = { ...remote, id: 'idle-project', name: 'Idle remote' }
|
||||
const props = {
|
||||
activeProjectId: project.id, projects: [project, remote, idle],
|
||||
remoteProjectsEnabled: true, onArchive: vi.fn(), onCreate: vi.fn(),
|
||||
onDelete: vi.fn(), onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
|
||||
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
|
||||
activityByProjectId: {
|
||||
[project.id]: { running: 1, attention: 0 },
|
||||
[remote.id]: { running: 2, attention: 3 }
|
||||
}
|
||||
}
|
||||
const { rerender } = render(<ProjectSwitcher {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
const menu = screen.getByRole('menu', { name: '当前项目' })
|
||||
await within(menu).findByRole('group', { name: 'SSH 主机:Build host' })
|
||||
for (const stage of ['agent', 'failed', 'completed'] as const) {
|
||||
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
|
||||
[remote.id]: { projectId: remote.id, requestId: 'activity-recovery', stage,
|
||||
message: 'Host unreachable', retryable: true }
|
||||
}} />)
|
||||
act(() => api.emitConnectionStatus(stage === 'agent' ? 'connecting' : stage === 'failed' ? 'disconnected' : 'ready'))
|
||||
const rows = within(menu).getAllByRole('menuitemradio')
|
||||
expect(rows.map((row) => row.querySelector('b')?.textContent))
|
||||
.toEqual(['Local project', 'Remote activity', 'Idle remote'])
|
||||
expect(rows[0]!.querySelector('.project-switcher__project-heading'))
|
||||
.toHaveTextContent('Local project 1 个运行中')
|
||||
const heading = rows[1]!.querySelector('.project-switcher__project-heading')!
|
||||
expect(heading).toHaveTextContent('Remote activity3 个待处理 2 个运行中')
|
||||
expect(heading.querySelector('b')?.nextElementSibling).toHaveClass('project-activity__counts')
|
||||
expect(rows[2]!.querySelector('.project-activity__counts')).toBeNull()
|
||||
}
|
||||
fireEvent.click(within(menu).getByRole('menuitemradio', { name: /Remote activity/u }))
|
||||
expect(props.onSelect).toHaveBeenCalledExactlyOnceWith(remote.id)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProjectSwitcher runtime fields', () => {
|
||||
it('creates an ordinary project with DeepSeek Harness', async () => {
|
||||
const { onCreate } = renderSwitcher()
|
||||
@@ -611,6 +656,112 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expires completion feedback once per request across project switches and menu mounts', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
installRemoteApi()
|
||||
vi.useFakeTimers()
|
||||
const remote: AssistantProject = {
|
||||
...project,
|
||||
id: remoteProjectId,
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
|
||||
}
|
||||
const props = {
|
||||
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
|
||||
onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
|
||||
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
|
||||
projects: [project, remote], remoteProjectsEnabled: true
|
||||
}
|
||||
const completed: RemoteProjectRecoveryState = {
|
||||
projectId: remote.id,
|
||||
requestId: '00000000-0000-4000-8000-000000000301',
|
||||
stage: 'completed'
|
||||
}
|
||||
const view = (activeProjectId: string, state: RemoteProjectRecoveryState = completed) => (
|
||||
<ProjectSwitcher {...props} activeProjectId={activeProjectId}
|
||||
recoveryByProjectId={{ [remote.id]: { ...state } }} />
|
||||
)
|
||||
const { rerender, unmount } = render(view(remote.id))
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
expect(within(trigger).getByRole('status')).toHaveTextContent('Recovery completed')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
||||
rerender(view(project.id))
|
||||
fireEvent.click(trigger)
|
||||
expect(within(screen.getByRole('menu')).getByRole('status'))
|
||||
.toHaveTextContent('Recovery completed')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(999) })
|
||||
expect(screen.getByText('Recovery completed')).toBeInTheDocument()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
rerender(view(remote.id))
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
expect(completed.stage).toBe('completed')
|
||||
|
||||
const next = { ...completed, requestId: '00000000-0000-4000-8000-000000000302' }
|
||||
rerender(view(remote.id, { ...next, stage: 'network' }))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(within(trigger).getByRole('status')).toHaveAttribute('aria-busy', 'true')
|
||||
rerender(view(remote.id, next))
|
||||
expect(screen.getAllByText('Recovery completed')).toHaveLength(2)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
rerender(view(remote.id, { ...next, requestId: 'new-request' }))
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('expires background completions independently and cancels obsolete completion timers', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
installRemoteApi()
|
||||
vi.useFakeTimers()
|
||||
const remote: AssistantProject = {
|
||||
...project, id: remoteProjectId,
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
|
||||
}
|
||||
const second = { ...remote, id: 'second-project', name: 'Second remote' }
|
||||
const onRetryRecovery = vi.fn(async () => undefined)
|
||||
const props = {
|
||||
activeProjectId: project.id,
|
||||
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
|
||||
onRemoteCommitted: vi.fn(), onSelect: vi.fn(), onRetryRecovery,
|
||||
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
|
||||
projects: [project, remote, second], remoteProjectsEnabled: true
|
||||
}
|
||||
const firstState: RemoteProjectRecoveryState = {
|
||||
projectId: remote.id, requestId: 'first-request', stage: 'completed'
|
||||
}
|
||||
const secondState: RemoteProjectRecoveryState = {
|
||||
projectId: second.id, requestId: 'second-request', stage: 'completed'
|
||||
}
|
||||
const { rerender } = render(<ProjectSwitcher {...props}
|
||||
recoveryByProjectId={{ [remote.id]: firstState }} />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
||||
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
|
||||
[remote.id]: { ...firstState }, [second.id]: secondState
|
||||
}} />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(within(menu).getAllByText('Recovery completed')).toHaveLength(1)
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /Second remote/u }))
|
||||
.toHaveTextContent('Recovery completed')
|
||||
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
|
||||
[remote.id]: firstState,
|
||||
[second.id]: { ...secondState, requestId: 'retry-request', stage: 'failed',
|
||||
message: 'Host unreachable', retryable: true }
|
||||
}} />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(within(menu).getByRole('alert')).toHaveTextContent('Host unreachable')
|
||||
await act(async () => {
|
||||
fireEvent.click(within(menu).getByRole('menuitem', {
|
||||
name: 'Retry recovery for project Second remote'
|
||||
}))
|
||||
})
|
||||
expect(onRetryRecovery).toHaveBeenCalledWith(second.id)
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an accessible failure and retries only the affected project', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const remoteProject: AssistantProject = {
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
} from './ChannelProjectSettingsFields'
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
|
||||
import { ProjectActivityCounts } from './ProjectActivity'
|
||||
import type { ConversationActivitySummary } from './conversation-activity'
|
||||
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
@@ -57,6 +59,7 @@ import { displayErrorMessage } from './error-message'
|
||||
type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
activityByProjectId?: ConversationActivitySummary['byProjectId']
|
||||
remoteProjectsEnabled?: boolean
|
||||
runtimeSettings?: RuntimeSettings
|
||||
onArchive: (projectId: string) => Promise<void>
|
||||
@@ -207,6 +210,7 @@ function isRemoteAbsolutePath(value: string): boolean {
|
||||
export function ProjectSwitcher({
|
||||
projects,
|
||||
activeProjectId,
|
||||
activityByProjectId = {},
|
||||
remoteProjectsEnabled = false,
|
||||
runtimeSettings,
|
||||
onArchive,
|
||||
@@ -281,6 +285,55 @@ export function ProjectSwitcher({
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
|
||||
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
|
||||
useState<string>()
|
||||
const [dismissedRecoveryByProjectId, setDismissedRecoveryByProjectId] =
|
||||
useState<Record<string, string>>({})
|
||||
const recoveryCompletionTimers = useRef(
|
||||
new Map<string, { requestId: string; timer: ReturnType<typeof setTimeout> }>()
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const timers = recoveryCompletionTimers.current
|
||||
for (const [projectId, entry] of timers) {
|
||||
const state = recoveryByProjectId[projectId]
|
||||
if (state?.stage !== 'completed' || state.requestId !== entry.requestId) {
|
||||
clearTimeout(entry.timer)
|
||||
timers.delete(projectId)
|
||||
}
|
||||
}
|
||||
for (const [projectId, state] of Object.entries(recoveryByProjectId)) {
|
||||
if (state.stage !== 'completed' || timers.has(projectId)) {
|
||||
continue
|
||||
}
|
||||
// Keep the request entry after expiry so menu mounts and snapshots cannot replay it.
|
||||
timers.set(projectId, {
|
||||
requestId: state.requestId,
|
||||
timer: setTimeout(() => {
|
||||
setDismissedRecoveryByProjectId((current) => ({
|
||||
...current,
|
||||
[projectId]: state.requestId
|
||||
}))
|
||||
}, 3_000)
|
||||
})
|
||||
}
|
||||
}, [recoveryByProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = recoveryCompletionTimers.current
|
||||
return () => {
|
||||
for (const entry of timers.values()) {
|
||||
clearTimeout(entry.timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleRecoveryByProjectId = Object.fromEntries(
|
||||
Object.entries(recoveryByProjectId).filter(
|
||||
([projectId, state]) =>
|
||||
state.stage !== 'completed' ||
|
||||
dismissedRecoveryByProjectId[projectId] !== state.requestId
|
||||
)
|
||||
)
|
||||
const applySshHostsSnapshot = useCallback(
|
||||
(
|
||||
snapshot: SshHostsSnapshot,
|
||||
@@ -357,7 +410,7 @@ export function ProjectSwitcher({
|
||||
: undefined
|
||||
const activeProjectRecovery =
|
||||
activeProject?.executionSpace.kind === 'ssh'
|
||||
? recoveryByProjectId[activeProject.id]
|
||||
? visibleRecoveryByProjectId[activeProject.id]
|
||||
: undefined
|
||||
const settingsProject = projects.find(
|
||||
(project) => project.id === settingsProjectId
|
||||
@@ -1019,7 +1072,7 @@ export function ProjectSwitcher({
|
||||
const projectDisplay = getProjectDisplayText(project, t)
|
||||
const recovery =
|
||||
project.executionSpace.kind === 'ssh'
|
||||
? recoveryByProjectId[project.id]
|
||||
? visibleRecoveryByProjectId[project.id]
|
||||
: undefined
|
||||
const detail =
|
||||
project.kind === 'channel'
|
||||
@@ -1062,7 +1115,12 @@ export function ProjectSwitcher({
|
||||
>
|
||||
<ProjectIcon aria-hidden="true" size={16} />
|
||||
<span>
|
||||
<b>{projectDisplay.name}</b>
|
||||
<span className="project-switcher__project-heading">
|
||||
<b>{projectDisplay.name}</b>
|
||||
{activityByProjectId[project.id] && (
|
||||
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
|
||||
)}
|
||||
</span>
|
||||
<small>{detail}</small>
|
||||
{recovery && <ProjectRecoveryStatus state={recovery} />}
|
||||
</span>
|
||||
@@ -1388,9 +1446,10 @@ export function ProjectSwitcher({
|
||||
onClick={() => closeDialog()}
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="project-create-card__body">
|
||||
{dialogMode === 'settings' &&
|
||||
settingsProject?.kind === 'channel' &&
|
||||
runtimeSettings ? (
|
||||
@@ -2019,6 +2078,7 @@ export function ProjectSwitcher({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<div className="project-create-card__actions">
|
||||
{dialogMode === 'settings' &&
|
||||
settingsProject?.kind !== 'channel' &&
|
||||
|
||||
@@ -121,9 +121,9 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(capabilitiesTabs).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
|
||||
it('keeps whole-surface dialog scrollbars outside close-button hitboxes', () => {
|
||||
it('keeps dialog scrollbars outside close-button hitboxes', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/:is\(\s*\.project-create-card,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u
|
||||
/:is\(\s*\.project-create-card__body,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveConversationActivity } from './conversation-activity'
|
||||
|
||||
const projects = [{ id: 'local', name: 'Local' }, { id: 'remote', name: 'Remote' }]
|
||||
|
||||
describe('deriveConversationActivity', () => {
|
||||
it('counts each conversation once with question and approval ahead of task fallback', () => {
|
||||
const conversations = [
|
||||
{ id: 'question', title: 'Question', projectId: 'remote', messages: [
|
||||
{ state: 'streaming', approval: {} }, { pendingQuestions: [{}, {}] }
|
||||
] },
|
||||
{ id: 'approval', title: 'Approval', projectId: 'local', messages: [{ approval: {} }] },
|
||||
{ id: 'fallback', title: 'Fallback', projectId: 'remote', messages: [{ state: 'streaming' }] }
|
||||
]
|
||||
const tasks = conversations.flatMap((conversation) => [
|
||||
{ conversationId: conversation.id, title: 'Task', status: 'running' },
|
||||
{ conversationId: conversation.id, title: 'Task', status: 'waiting_approval' }
|
||||
])
|
||||
const summary = deriveConversationActivity(
|
||||
conversations, tasks, new Set(conversations.map(({ id }) => id)), projects, 'Unknown'
|
||||
)
|
||||
expect(summary.activities.map(({ status }) => status)).toEqual(['question', 'approval', 'attention'])
|
||||
expect(summary).toMatchObject({
|
||||
running: 0, attention: 3,
|
||||
byProjectId: { remote: { running: 0, attention: 2 }, local: { running: 0, attention: 1 } }
|
||||
})
|
||||
expect(deriveConversationActivity(conversations, [...tasks].reverse(), new Set(), projects, 'Unknown'))
|
||||
.toEqual(summary)
|
||||
})
|
||||
|
||||
it('includes background active IDs, streams, and tasks with unloaded conversation metadata', () => {
|
||||
const summary = deriveConversationActivity([
|
||||
{ id: 'active', title: 'Active', projectId: 'local', messages: [] },
|
||||
{ id: 'stream', title: 'Stream', projectId: 'remote', messages: [{ state: 'streaming' }] },
|
||||
{ id: 'known', title: 'Conversation title', messages: [] }
|
||||
], [
|
||||
{ conversationId: 'unloaded', projectId: 'remote', title: 'Task title', status: 'running' },
|
||||
{ conversationId: 'unloaded', projectId: 'remote', title: 'Task title', status: 'waiting_approval' },
|
||||
{ conversationId: 'known', projectId: 'local', title: 'Wrong title', status: 'running' },
|
||||
{ conversationId: 'missing-project', projectId: 'deleted', title: 'Missing project', status: 'running' }
|
||||
], new Set(['active', 'no-metadata']), projects, 'Unknown')
|
||||
expect(summary).toMatchObject({
|
||||
running: 4, attention: 1,
|
||||
byProjectId: {
|
||||
local: { running: 1, attention: 0 },
|
||||
remote: { running: 1, attention: 1 },
|
||||
deleted: { running: 1, attention: 0 }
|
||||
}
|
||||
})
|
||||
expect(summary.activities).toContainEqual({
|
||||
conversationId: 'unloaded', projectId: 'remote', title: 'Task title', projectName: 'Remote', status: 'attention'
|
||||
})
|
||||
expect(summary.activities).toContainEqual({
|
||||
conversationId: 'known', projectId: undefined, title: 'Conversation title', projectName: 'Unknown', status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes idle, queued and terminal work and tasks without conversation IDs', () => {
|
||||
const summary = deriveConversationActivity([
|
||||
{ id: 'idle', title: 'Idle', messages: [{ state: 'complete', approval: null, pendingQuestions: [] }] }
|
||||
], [
|
||||
...['queued', 'completed', 'failed', 'interrupted', 'cancelled', 'paused'].map((status) => ({
|
||||
conversationId: status, title: status, status
|
||||
})),
|
||||
{ title: 'No conversation', status: 'running' }
|
||||
], new Set(), projects, 'Unknown')
|
||||
expect(summary).toEqual({ activities: [], byProjectId: {}, running: 0, attention: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
export type ConversationActivity = {
|
||||
conversationId: string
|
||||
projectId?: string
|
||||
title: string
|
||||
projectName: string
|
||||
status: 'running' | 'approval' | 'question' | 'attention'
|
||||
}
|
||||
|
||||
export type ConversationActivitySummary = {
|
||||
activities: ConversationActivity[]
|
||||
byProjectId: Record<string, { running: number; attention: number }>
|
||||
running: number
|
||||
attention: number
|
||||
}
|
||||
|
||||
export function deriveConversationActivity(
|
||||
conversations: readonly {
|
||||
id: string
|
||||
title: string
|
||||
projectId?: string
|
||||
messages: readonly { state?: string; approval?: unknown; pendingQuestions?: readonly unknown[] }[]
|
||||
}[],
|
||||
tasks: readonly {
|
||||
conversationId?: string
|
||||
projectId?: string
|
||||
title: string
|
||||
status: string
|
||||
}[],
|
||||
activeConversationIds: ReadonlySet<string>,
|
||||
projects: readonly { id: string; name: string }[],
|
||||
fallbackProjectName: string
|
||||
): ConversationActivitySummary {
|
||||
const projectNames = new Map(projects.map((project) => [project.id, project.name]))
|
||||
const metadata = new Map(conversations.map((conversation) => [conversation.id, conversation]))
|
||||
const rows = new Map<string, ConversationActivity>()
|
||||
const priority = { running: 0, attention: 1, approval: 2, question: 3 }
|
||||
|
||||
for (const conversation of conversations) {
|
||||
let status: ConversationActivity['status'] | undefined =
|
||||
activeConversationIds.has(conversation.id) ? 'running' : undefined
|
||||
for (const message of conversation.messages) {
|
||||
const candidate = message.pendingQuestions?.length
|
||||
? 'question'
|
||||
: message.approval
|
||||
? 'approval'
|
||||
: message.state === 'streaming'
|
||||
? 'running'
|
||||
: undefined
|
||||
if (candidate && (!status || priority[candidate] > priority[status])) {
|
||||
status = candidate
|
||||
}
|
||||
}
|
||||
if (status) {
|
||||
rows.set(conversation.id, {
|
||||
conversationId: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
title: conversation.title,
|
||||
projectName: projectNames.get(conversation.projectId ?? '') ?? fallbackProjectName,
|
||||
status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.conversationId || (task.status !== 'running' && task.status !== 'waiting_approval')) {
|
||||
continue
|
||||
}
|
||||
// Tasks use waiting_approval for both approvals and questions.
|
||||
const status = task.status === 'waiting_approval' ? 'attention' : 'running'
|
||||
const existing = rows.get(task.conversationId)
|
||||
if (existing) {
|
||||
if (priority[status] > priority[existing.status]) existing.status = status
|
||||
continue
|
||||
}
|
||||
const conversation = metadata.get(task.conversationId)
|
||||
const projectId = conversation ? conversation.projectId : task.projectId
|
||||
rows.set(task.conversationId, {
|
||||
conversationId: task.conversationId,
|
||||
projectId,
|
||||
title: conversation ? conversation.title : task.title,
|
||||
projectName: projectNames.get(projectId ?? '') ?? fallbackProjectName,
|
||||
status
|
||||
})
|
||||
}
|
||||
|
||||
const summary: ConversationActivitySummary = {
|
||||
activities: [...rows.values()],
|
||||
byProjectId: {},
|
||||
running: 0,
|
||||
attention: 0
|
||||
}
|
||||
const counts = new Map<string, { running: number; attention: number }>()
|
||||
for (const activity of summary.activities) {
|
||||
const category = activity.status === 'running' ? 'running' : 'attention'
|
||||
summary[category] += 1
|
||||
if (activity.projectId !== undefined) {
|
||||
const project = counts.get(activity.projectId) ?? { running: 0, attention: 0 }
|
||||
project[category] += 1
|
||||
counts.set(activity.projectId, project)
|
||||
}
|
||||
}
|
||||
summary.byProjectId = Object.fromEntries(counts)
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.external-knowledge { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-4); min-width: 0; min-height: 0; overflow: auto; }
|
||||
.external-knowledge .external-knowledge { padding: 0; overflow: visible; }
|
||||
.external-knowledge__columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-6); }
|
||||
.external-knowledge__fields { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
|
||||
.external-knowledge__fields h3, .external-knowledge p, .external-knowledge-modal p { margin: 0; }
|
||||
.external-knowledge__fields > button { align-self: flex-start; }
|
||||
.external-knowledge__group { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; margin: 0; padding: var(--space-3) 0 0; border: 0; border-top: 1px solid var(--border-subtle); }
|
||||
.external-knowledge__group legend { padding: 0 var(--space-2) 0 0; font-size: var(--font-section-title); font-weight: 650; }
|
||||
.external-knowledge__help { color: var(--text-secondary); font-size: var(--font-caption); line-height: 1.5; }
|
||||
.external-knowledge__help summary { cursor: pointer; }
|
||||
.external-knowledge__help[open] summary { margin-bottom: var(--space-2); }
|
||||
.external-knowledge__password { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.external-knowledge__password input { min-width: 0; flex: 1; }
|
||||
.external-knowledge__password .icon-button { flex: 0 0 auto; }
|
||||
.external-knowledge__instance-select > .model-button { width: 100%; }
|
||||
.external-knowledge__instance-select > .runtime-picker__menu { position: static; width: 100%; }
|
||||
.external-knowledge__instance-select > .runtime-picker__menu > button > small { white-space: normal; overflow-wrap: anywhere; }
|
||||
.external-knowledge__footer { position: sticky; bottom: calc(-1 * var(--space-4)); padding: var(--space-3) 0; background: var(--surface-raised); border-top: 1px solid var(--border-subtle); justify-content: flex-end; }
|
||||
.external-knowledge__footer > .scope-badge { margin-right: auto; }
|
||||
.external-knowledge__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||
.external-knowledge__catalog { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.external-knowledge__target { display: flex; text-align: left; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); }
|
||||
.external-knowledge__target[aria-pressed='true'] { background: var(--accent-subtle); border-color: var(--accent-selected); }
|
||||
.external-knowledge__target span { min-width: 0; }
|
||||
.external-knowledge__target small, .external-knowledge__instances small, .external-knowledge__results small { display: block; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.external-knowledge__identifier, .external-knowledge__results p { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.external-knowledge__results li { padding: var(--space-3); border-bottom: 1px solid var(--border-default); }
|
||||
.external-knowledge__instances { list-style: none; padding: 0; margin: var(--space-3) 0 0; }
|
||||
.external-knowledge__instances li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-4); border-bottom: 1px solid var(--border-default); }
|
||||
.external-knowledge-modal { position: fixed; inset: 0; z-index: var(--z-dialog); display: grid; place-items: center; padding: var(--space-4); background: var(--overlay-backdrop); }
|
||||
.external-knowledge-modal__surface { width: min(560px, 100%); max-height: calc(100dvh - 2 * var(--space-4)); min-width: 0; display: flex; flex-direction: column; background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); box-shadow: var(--shadow-dialog); overflow: hidden; }
|
||||
.external-knowledge-modal__surface > header { display: flex; justify-content: space-between; align-items: center; padding: var(--space-4); border-bottom: 1px solid var(--border-default); }
|
||||
.external-knowledge-modal__surface h2 { margin: 0; font-size: var(--font-section-title); }
|
||||
.external-knowledge-modal__surface > header > div { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||
.external-knowledge-modal__body { padding: var(--space-4); min-height: 0; overflow: auto; }
|
||||
.external-knowledge, .external-knowledge-modal__body { scroll-padding-block: var(--space-3) calc(var(--control-height) + 2 * var(--space-4)); }
|
||||
.external-knowledge__fieldset :is(input, select, textarea, summary) { scroll-margin-block: var(--space-3) calc(var(--control-height) + 2 * var(--space-4)); }
|
||||
.external-knowledge-modal__body .external-knowledge__columns { gap: var(--space-3); }
|
||||
.external-knowledge-modal__body .external-knowledge__fieldset { gap: var(--space-3); }
|
||||
.external-knowledge dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-2) var(--space-4); margin: 0; }
|
||||
.external-knowledge dt { color: var(--text-secondary); }
|
||||
.external-knowledge dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.external-knowledge > header h2 { margin: 0 0 var(--space-2); font-size: var(--font-section-title); }
|
||||
.external-knowledge > .page-tabs { flex: 0 0 auto; }
|
||||
.external-knowledge__fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.external-knowledge__filters { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3); flex: 0 0 auto; }
|
||||
.external-knowledge > header, .external-knowledge-modal__surface > header { flex: 0 0 auto; }
|
||||
.external-knowledge__target:hover:not(:disabled):not([aria-pressed='true']) { background: var(--surface-subtle); }
|
||||
.external-knowledge__target:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.external-knowledge__target:disabled { opacity: 0.6; }
|
||||
.external-knowledge__target strong, .external-knowledge__instances strong { overflow-wrap: anywhere; }
|
||||
@media (max-width: 959px) { .external-knowledge__columns { grid-template-columns: minmax(0, 1fr); } }
|
||||
@@ -2,9 +2,57 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { knowledge as chineseKnowledge } from '../zh-CN/knowledge'
|
||||
|
||||
export const knowledge = {
|
||||
external: {
|
||||
close: 'Close instance manager', deleteInstanceTitle: 'Delete instance “{{name}}”?',
|
||||
clearSearch: 'Clear search', noResultsHelp: 'Try a different name or clear the search to see available libraries.',
|
||||
emptyHelp: 'Check access on the remote service, or enter a known library ID manually.',
|
||||
sourceFilter: 'Library source filter', clearFilters: 'Clear filters', weightedScore: 'Weighted score',
|
||||
saveThenTest: 'Save the instance, then use Test connection in the instance list.',
|
||||
rebindConfirm: 'Save this binding with the newly selected remote library. Remote data will not change.',
|
||||
probe: { untested: 'Not tested', 'catalog-ready': 'Catalog available', healthy: 'Connected', 'list-restricted': 'Catalog restricted; enter a library ID manually', 'auth-failed': 'Authentication failed', unreachable: 'Service unreachable', failed: 'Connection failed' },
|
||||
credentials: { configured: 'Credential configured', missing: 'Credential missing', unavailable: 'Credential unavailable' },
|
||||
manage: 'Manage external instances', instances: 'External instances', addInstance: 'Add instance',
|
||||
type: 'Library type', local: 'Local', provider: 'Provider', instance: 'External instance',
|
||||
editInstance: 'Edit instance', authentication: 'Authentication', credentialAction: 'Saved credential', connectionHelp: 'How to test the connection',
|
||||
enabledHelp: 'Enabled bindings send retrieval queries to this service.',
|
||||
address: 'Service URL', credential: 'API Key', keep: 'Keep saved credential', replace: 'Replace credential', clear: 'Clear credential',
|
||||
credentialNote: 'Credentials are encrypted locally and are never returned after saving.', showKey: 'Show credential', hideKey: 'Hide credential',
|
||||
clearConfirm: 'Clearing the credential makes libraries using this instance unavailable for retrieval.',
|
||||
transportNote: 'This HTTP address sends credentials and queries without transport encryption.',
|
||||
transportConfirm: 'I understand and allow credentials to be sent to this address', enabled: 'Enable instance',
|
||||
testConnection: 'Test connection', saveInstance: 'Save instance', saved: 'Saved', removed: 'Removed from GoodBuddy',
|
||||
noInstances: 'No external instances configured', noInstancesHelp: 'Add your existing Dify, FastGPT, or RAGFlow service.',
|
||||
selectInstance: 'Select an external instance', remote: 'Remote library', remoteId: 'Remote library ID', remoteName: 'Remote name',
|
||||
manual: 'Enter library ID manually', browse: 'Choose from catalog', search: 'Search remote libraries', refresh: 'Refresh library catalog',
|
||||
loading: 'Loading libraries…', empty: 'No accessible libraries in this instance', noResults: 'No matching results',
|
||||
next: 'Next page', previous: 'Previous page', root: 'Back to root', folder: 'Open folder', alreadyBound: 'Already added',
|
||||
verify: 'Verify library', source: 'Library source', config: 'Retrieval settings', common: 'Common settings',
|
||||
resultLimit: 'Returned snippets', timeout: 'Request timeout (ms)', snippetLimit: 'Maximum characters per snippet',
|
||||
localOnly: 'Settings are saved only in GoodBuddy for subsequent retrievals through this binding. They do not change remote library settings.',
|
||||
queryNote: 'Only the current query is sent to the selected instance. Files and full conversations are not uploaded, and no GoodBuddy model is called. Retrieval may incur remote Embedding, Rerank usage and API records.',
|
||||
query: 'Test query', test: 'Test retrieval', testing: 'Testing…', tested: 'Retrieval succeeded with {{count}} snippets',
|
||||
untested: 'Enter a query and successfully test retrieval before saving. Zero results also allow saving. Saving runs the query again to verify the current settings.',
|
||||
add: 'Add library', saveConfig: 'Save retrieval settings', defaults: 'Restore defaults',
|
||||
datasetDefaults: 'Use dataset defaults', datasetDefaultsHelp: 'Each request uses the current remote settings. Remote changes will affect results.',
|
||||
searchMethod: 'Search method', topK: 'Provider Top K', thresholdEnabled: 'Enable score threshold', threshold: 'Similarity threshold',
|
||||
rerank: 'Enable Rerank', rerankMode: 'Reranking mode', rerankProvider: 'Reranking provider', rerankModel: 'Reranking model',
|
||||
keywordWeight: 'Keyword weight', vectorWeight: 'Vector weight', embeddingProvider: 'Embedding provider', embeddingModel: 'Embedding model',
|
||||
tokenLimit: 'Token limit', knnTopK: 'KNN Top K', rerankId: 'Remote reranking model ID', rerankCandidates: 'Rerank candidates',
|
||||
graph: 'Graph retrieval', graphHelp: 'Search existing remote graph content without creating or updating the graph.', graphUnknown: 'Completed remote graph not confirmed',
|
||||
compilation: 'Include Knowledge Compilation', compilationUnavailable: 'Knowledge Compilation configuration not confirmed for this library',
|
||||
overview: 'Overview', rebind: 'Change remote library', refreshDetail: 'Refresh remote details', remove: 'Remove from GoodBuddy',
|
||||
removeBindingConfirm: 'Only the local binding and retrieval settings are deleted. Remote libraries and documents are preserved. Historical conversation citations remain readable.',
|
||||
deleteInstance: 'Delete instance and local bindings', deleteInstanceConfirm: 'This instance is used by {{count}} libraries. Deletion also removes their local bindings. Remote data will not change.',
|
||||
cancelRequest: 'Cancel request', remoteData: 'Knowledge stays on the remote service. Snippets used in answers are saved locally with the conversation.',
|
||||
status: 'Status', credentialStatus: 'Credential status', boundCount: '{{count}} bindings', lastTest: 'Last test', never: 'Not retrieved yet',
|
||||
snapshot: 'Historical citation snapshot', documentId: 'Remote document ID', chunkId: 'Remote snippet ID', providerScore: 'Provider score',
|
||||
elapsed: 'Total {{ms}} ms', returned: '{{returned}} returned · {{kept}} retained', truncated: 'Results truncated',
|
||||
allSources: 'All sources', searchLibraries: 'Search libraries', selectTarget: 'Select a remote library',
|
||||
states: { ready: 'Remote retrieval ready', untested: 'Not tested', 'instance-disabled': 'Instance disabled', 'credential-error': 'Credential unavailable', 'remote-missing': 'Remote target missing', 'config-invalid': 'Settings need confirmation', 'temporarily-unavailable': 'Temporarily unavailable' }
|
||||
},
|
||||
page: {
|
||||
title: 'Knowledge Base',
|
||||
description: 'Manage files, folders, and web sources, then inspect their indexes and graph.'
|
||||
description: 'Manage local knowledge and retrieve from existing enterprise knowledge services.'
|
||||
},
|
||||
actions: {
|
||||
cancel: 'Cancel',
|
||||
|
||||
@@ -2,6 +2,20 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
|
||||
|
||||
export const workspace = {
|
||||
projectActivity: {
|
||||
title: 'All project activity',
|
||||
close: 'Close project activity',
|
||||
unassigned: 'No project',
|
||||
attentionCount: '{{count}} need attention',
|
||||
runningCount: '{{count}} running',
|
||||
groups: { attention: 'Needs attention', running: 'Running' },
|
||||
status: {
|
||||
running: 'Running',
|
||||
approval: 'Waiting for approval',
|
||||
question: 'Waiting for your answer',
|
||||
attention: 'Needs attention'
|
||||
}
|
||||
},
|
||||
management: {
|
||||
gitWorkspace: 'Git Workspace', more: 'More actions for {{name}}', browse: 'Browse directory', parent: 'Parent directory',
|
||||
createFile: 'New file', createDirectory: 'New directory', rename: 'Rename', move: 'Move', delete: 'Delete', properties: 'Properties',
|
||||
@@ -506,6 +520,9 @@ export const workspace = {
|
||||
empty: 'The workspace is empty.'
|
||||
},
|
||||
question: {
|
||||
task: 'Task: {{title}}',
|
||||
pendingCount: '{{count}} pending questions. Answer them in order.',
|
||||
locate: 'Go to question form',
|
||||
title: 'OpenCode needs more information',
|
||||
otherAnswer: 'Other answer',
|
||||
answerPlaceholder: 'Enter your answer',
|
||||
|
||||
@@ -1,7 +1,55 @@
|
||||
export const knowledge = {
|
||||
external: {
|
||||
close: '关闭实例管理', deleteInstanceTitle: '删除实例“{{name}}”?',
|
||||
clearSearch: '清除搜索', noResultsHelp: '换一个名称搜索,或清除搜索查看可用知识库。',
|
||||
emptyHelp: '请检查远端服务的访问权限,或手工填写已知的知识库 ID。',
|
||||
sourceFilter: '知识库来源筛选', clearFilters: '清除筛选', weightedScore: '加权分数',
|
||||
saveThenTest: '保存实例后,可在实例列表中测试连接。',
|
||||
rebindConfirm: '将此绑定保存为新选择的远端知识库,远端数据不会改变。',
|
||||
probe: { untested: '未测试', 'catalog-ready': '目录可用', healthy: '连接正常', 'list-restricted': '目录受限,可手工填写知识库 ID', 'auth-failed': '认证失败', unreachable: '服务无法连接', failed: '连接失败' },
|
||||
credentials: { configured: '已配置凭据', missing: '缺少凭据', unavailable: '凭据不可用' },
|
||||
manage: '管理外部实例', instances: '外部实例', addInstance: '添加实例',
|
||||
type: '知识库类型', local: '本地', provider: 'Provider', instance: '外部实例',
|
||||
editInstance: '编辑实例', authentication: '身份认证', credentialAction: '已保存凭据', connectionHelp: '如何测试连接',
|
||||
enabledHelp: '启用后,关联知识库的检索查询将发送到此服务。',
|
||||
address: '服务地址', credential: 'API Key', keep: '保留已保存凭据', replace: '替换凭据', clear: '清除凭据',
|
||||
credentialNote: '凭据由本机加密保存,保存后不会回显。', showKey: '显示凭据', hideKey: '隐藏凭据',
|
||||
clearConfirm: '清除凭据后,使用此实例的知识库将无法检索。',
|
||||
transportNote: '此 HTTP 地址通过未加密连接传输凭据和查询。',
|
||||
transportConfirm: '我了解并允许向此地址发送凭据', enabled: '启用实例',
|
||||
testConnection: '测试连接', saveInstance: '保存实例', saved: '已保存', removed: '已从 GoodBuddy 移除',
|
||||
noInstances: '尚未配置外部实例', noInstancesHelp: '添加企业现有的 Dify、FastGPT 或 RAGFlow 服务。',
|
||||
selectInstance: '选择外部实例', remote: '远端知识库', remoteId: '远端知识库 ID', remoteName: '远端名称',
|
||||
manual: '手工填写知识库 ID', browse: '从目录选择', search: '搜索远端知识库', refresh: '刷新知识库列表',
|
||||
loading: '正在读取知识库…', empty: '当前实例没有可访问的知识库', noResults: '没有符合条件的结果',
|
||||
next: '下一页', previous: '上一页', root: '返回根目录', folder: '打开文件夹', alreadyBound: '已添加',
|
||||
verify: '验证知识库', source: '知识库来源', config: '检索配置', common: '通用配置',
|
||||
resultLimit: '返回片段数', timeout: '请求超时(毫秒)', snippetLimit: '单片段字符上限',
|
||||
localOnly: '参数仅保存在 GoodBuddy,用于此绑定的后续检索,不会修改远端知识库设置。',
|
||||
queryNote: '只向所选实例发送当前查询,不上传文件或完整会话,不调用 GoodBuddy 模型。检索可能产生远端 Embedding、Rerank 用量及 API 使用记录。',
|
||||
query: '测试查询', test: '测试检索', testing: '正在测试…', tested: '检索成功,返回 {{count}} 条片段',
|
||||
untested: '请填写查询并成功测试检索后保存。零条结果也可保存。保存时会再次发送查询,验证当前参数。',
|
||||
add: '添加知识库', saveConfig: '保存检索参数', defaults: '恢复默认',
|
||||
datasetDefaults: '使用知识库默认配置', datasetDefaultsHelp: '每次检索沿用远端当前设置,远端设置变化会影响结果。',
|
||||
searchMethod: '检索方式', topK: 'Provider Top K', thresholdEnabled: '启用分数阈值', threshold: '相似度阈值',
|
||||
rerank: '启用 Rerank', rerankMode: '重排方式', rerankProvider: '重排模型提供商', rerankModel: '重排模型',
|
||||
keywordWeight: '关键词权重', vectorWeight: '向量权重', embeddingProvider: 'Embedding 提供商', embeddingModel: 'Embedding 模型',
|
||||
tokenLimit: 'Token 上限', knnTopK: 'KNN Top K', rerankId: '远端重排模型 ID', rerankCandidates: '重排候选数量',
|
||||
graph: '图谱检索', graphHelp: '检索远端已生成的图谱,不会创建或更新图谱。', graphUnknown: '未确认远端图谱已完成',
|
||||
compilation: '包含 Knowledge Compilation', compilationUnavailable: '当前知识库未确认配置 Knowledge Compilation',
|
||||
overview: '概览', rebind: '更换远端知识库', refreshDetail: '刷新远端信息', remove: '从 GoodBuddy 移除',
|
||||
removeBindingConfirm: '仅删除本机绑定和检索配置,不删除远端知识库或文档。历史会话中的引用继续可读。',
|
||||
deleteInstance: '删除实例和本地绑定', deleteInstanceConfirm: '此实例被 {{count}} 个知识库使用。删除会同时移除本机绑定,远端数据不会改变。',
|
||||
cancelRequest: '取消请求', remoteData: '知识内容保留在远端;回答使用的片段随会话保存在本机。',
|
||||
status: '状态', credentialStatus: '凭据状态', boundCount: '{{count}} 个绑定', lastTest: '最近测试', never: '尚未检索',
|
||||
snapshot: '历史引用快照', documentId: '远端文档 ID', chunkId: '远端片段 ID', providerScore: 'Provider 分数',
|
||||
elapsed: '总耗时 {{ms}} ms', returned: '返回 {{returned}} 条 · 保留 {{kept}} 条', truncated: '结果已截断',
|
||||
allSources: '全部来源', searchLibraries: '搜索知识库', selectTarget: '选择远端知识库',
|
||||
states: { ready: '远端可检索', untested: '未测试', 'instance-disabled': '实例已停用', 'credential-error': '凭据不可用', 'remote-missing': '远端目标不存在', 'config-invalid': '配置需要确认', 'temporarily-unavailable': '暂时无法连接' }
|
||||
},
|
||||
page: {
|
||||
title: '知识库',
|
||||
description: '管理文件、目录和网页来源,并查看索引与图谱。'
|
||||
description: '管理本地知识,并检索企业现有知识平台。'
|
||||
},
|
||||
actions: {
|
||||
cancel: '取消',
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
export const workspace = {
|
||||
projectActivity: {
|
||||
title: '全项目活动',
|
||||
close: '关闭全项目活动',
|
||||
unassigned: '未归属项目',
|
||||
attentionCount: '{{count}} 个待处理',
|
||||
runningCount: '{{count}} 个运行中',
|
||||
groups: { attention: '待处理', running: '运行中' },
|
||||
status: {
|
||||
running: '运行中',
|
||||
approval: '等待审批',
|
||||
question: '等待你的回答',
|
||||
attention: '待处理'
|
||||
}
|
||||
},
|
||||
management: {
|
||||
gitWorkspace: 'Git 工作区', more: '{{name}} 的更多操作', browse: '浏览此目录', parent: '返回父目录',
|
||||
createFile: '新建文件', createDirectory: '新建目录', rename: '重命名', move: '移动', delete: '删除', properties: '属性',
|
||||
@@ -493,6 +507,9 @@ export const workspace = {
|
||||
empty: '工作区为空。'
|
||||
},
|
||||
question: {
|
||||
task: '任务:{{title}}',
|
||||
pendingCount: '还有 {{count}} 个待答问题,请依次回答。',
|
||||
locate: '前往问答表单',
|
||||
title: 'OpenCode 需要补充信息',
|
||||
otherAnswer: '其他回答',
|
||||
answerPlaceholder: '输入你的回答',
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
.project-activity__summary,
|
||||
.project-activity__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.project-activity__summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
flex-shrink: 0;
|
||||
row-gap: var(--space-1);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-activity__summary > span { grid-column: 1; }
|
||||
.project-activity__summary > span:first-child { color: var(--text-secondary); }
|
||||
.project-activity__summary > svg { grid-column: 2; grid-row: 1 / 3; }
|
||||
|
||||
.project-activity__summary > svg,
|
||||
.project-activity__row > svg {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.project-activity__summary:hover,
|
||||
.project-activity__row:hover {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.project-activity__summary:active,
|
||||
.project-activity__row:active {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.project-activity__summary:focus-visible,
|
||||
.project-activity__dialog button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.project-activity__counts,
|
||||
.project-activity__counts > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.project-activity__counts {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-activity__attention { color: var(--warning); }
|
||||
.project-activity__running { color: var(--accent); }
|
||||
|
||||
.project-switcher__project-heading {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.project-switcher__project-heading > b { min-width: 0; }
|
||||
|
||||
.conversation-activity-indicator.conversation-activity-indicator--attention {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--warning) 14%, transparent);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.project-activity__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4);
|
||||
background: var(--overlay-backdrop);
|
||||
}
|
||||
|
||||
.project-activity__dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 520px);
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.project-activity__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.project-activity__header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
|
||||
.project-activity__content {
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.project-activity__content h3 {
|
||||
margin: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-activity__content section + section { margin-top: var(--space-4); }
|
||||
.project-activity__content ul { margin: 0; padding: 0; list-style: none; }
|
||||
|
||||
.project-activity__identity {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-activity__identity small {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-activity__row > .project-activity__attention,
|
||||
.project-activity__row > .project-activity__running {
|
||||
max-width: 40%;
|
||||
font-size: var(--font-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-activity__summary,
|
||||
.project-activity__row { transition: none; }
|
||||
}
|
||||
@@ -2890,7 +2890,7 @@ textarea:focus-visible {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
flex-direction: column;
|
||||
@@ -2898,7 +2898,7 @@ textarea:focus-visible {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.project-create-card header {
|
||||
.project-create-card > header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
@@ -2909,13 +2909,29 @@ textarea:focus-visible {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.project-create-card__close {
|
||||
.project-create-card .project-create-card__close {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.project-create-card__body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
padding: 3px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-create-card__body > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-create-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2962,6 +2978,8 @@ textarea:focus-visible {
|
||||
|
||||
.project-create-card__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
@@ -3787,7 +3805,7 @@ button > svg {
|
||||
}
|
||||
|
||||
:is(
|
||||
.project-create-card,
|
||||
.project-create-card__body,
|
||||
.knowledge-citation-dialog__surface,
|
||||
.channel-qr-dialog,
|
||||
.document-parsing-diagnostic,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { externalKnowledgeLocatorSchema } from './external-knowledge-contracts'
|
||||
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
|
||||
import { sshHostIdSchema } from './ssh-host-contracts'
|
||||
|
||||
@@ -368,7 +369,9 @@ export const conversationMessageSchema = z
|
||||
.object({
|
||||
libraryId: assistantIdSchema,
|
||||
libraryName: z.string().max(200),
|
||||
documentId: assistantIdSchema,
|
||||
documentId: assistantIdSchema.optional(),
|
||||
external: externalKnowledgeLocatorSchema.optional(),
|
||||
warnings: z.array(z.string().max(500)).max(20).optional(),
|
||||
chunkId: assistantIdSchema.optional(),
|
||||
documentName: z.string().max(500),
|
||||
sourceName: z.string().max(500),
|
||||
|
||||
+21
-1
@@ -1308,6 +1308,7 @@ export type AgentEvent =
|
||||
requestId: string
|
||||
type: 'question'
|
||||
questionId: string
|
||||
childTaskId?: string
|
||||
questions: Array<{
|
||||
header: string
|
||||
question: string
|
||||
@@ -1319,6 +1320,11 @@ export type AgentEvent =
|
||||
custom: boolean
|
||||
}>
|
||||
}
|
||||
| {
|
||||
requestId: string
|
||||
type: 'question-resolved'
|
||||
questionId: string
|
||||
}
|
||||
| {
|
||||
requestId: string
|
||||
type: 'artifact'
|
||||
@@ -1633,6 +1639,8 @@ export const knowledgeRelationInputSchema = z
|
||||
.strict()
|
||||
|
||||
export type KnowledgeLibrary = z.infer<typeof knowledgeCreateSchema> & {
|
||||
kind?: 'local' | 'external'
|
||||
external?: import('./external-knowledge-contracts').ExternalKnowledgeBinding
|
||||
id: string
|
||||
sourceCount: number
|
||||
documentCount: number
|
||||
@@ -1717,9 +1725,11 @@ export type KnowledgeSnapshot = {
|
||||
}
|
||||
|
||||
export type KnowledgeSearchReference = {
|
||||
warnings?: string[]
|
||||
external?: import('./external-knowledge-contracts').ExternalKnowledgeLocator
|
||||
libraryId: string
|
||||
libraryName: string
|
||||
documentId: string
|
||||
documentId?: string
|
||||
chunkId?: string
|
||||
documentName: string
|
||||
sourceName: string
|
||||
@@ -2274,6 +2284,16 @@ export type DesktopApi = {
|
||||
onTodoStatusChanged: (listener: () => void) => () => void
|
||||
}
|
||||
knowledge: {
|
||||
externalInstancesList: () => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary[]>
|
||||
externalInstancesSave: (input: import('./external-knowledge-contracts').ExternalKnowledgeInstanceSaveInput) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary>
|
||||
externalInstancesTest: (input: {instanceId:string}) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary>
|
||||
externalInstancesSetEnabled: (input: {instanceId:string;enabled:boolean}) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary>
|
||||
externalInstancesDelete: (input: {instanceId:string}) => Promise<void>
|
||||
externalCatalogList: (input: {instanceId:string;page?:number;pageSize?:number;parentId?:string|null;search?:string}) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeCatalogPage>
|
||||
externalCatalogGet: (input: {instanceId:string;remoteKnowledgeBaseId:string}) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeCatalogItem>
|
||||
externalBindingsCreate: (input: import('./external-knowledge-contracts').ExternalKnowledgeBindingSaveInput) => Promise<KnowledgeSnapshot>
|
||||
externalBindingsUpdate: (input: import('./external-knowledge-contracts').ExternalKnowledgeBindingSaveInput & {knowledgeBaseId:string}) => Promise<KnowledgeSnapshot>
|
||||
externalRetrievalTest: (input: import('./external-knowledge-contracts').ExternalKnowledgeBindingTestInput) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeTestResult>
|
||||
getSnapshot: (libraryId?: string) => Promise<KnowledgeSnapshot>
|
||||
createLibrary: (
|
||||
input: z.infer<typeof knowledgeCreateSchema>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const id = z.string().trim().min(1).max(512)
|
||||
export const externalKnowledgeProviderSchema = z.enum(['dify', 'fastgpt', 'ragflow'])
|
||||
export type ExternalKnowledgeProvider = z.infer<typeof externalKnowledgeProviderSchema>
|
||||
export const externalKnowledgeCommonConfigSchema = z.object({
|
||||
resultLimit: z.number().int().min(1).max(20).default(6),
|
||||
requestTimeoutMs: z.number().int().min(1000).max(60000).default(15000),
|
||||
maxSnippetCharacters: z.number().int().min(100).max(8000).default(4000)
|
||||
}).strict()
|
||||
export type ExternalKnowledgeCommonConfig = z.infer<typeof externalKnowledgeCommonConfigSchema>
|
||||
const retrievalModel = z.object({
|
||||
search_method: z.enum(['keyword_search', 'semantic_search', 'full_text_search', 'hybrid_search']),
|
||||
reranking_enable: z.boolean(), top_k: z.number().int().min(1).max(20),
|
||||
score_threshold_enabled: z.boolean(), score_threshold: z.number().min(0).max(1).optional(),
|
||||
reranking_mode: z.enum(['reranking_model', 'weighted_score']).optional(),
|
||||
reranking_model: z.object({reranking_provider_name: id, reranking_model_name: id}).strict().optional(),
|
||||
weights: z.object({weight_type: z.literal('customized'), vector_setting: z.object({vector_weight: z.number().min(0).max(1), embedding_provider_name: id, embedding_model_name: id}).strict(), keyword_setting: z.object({keyword_weight: z.number().min(0).max(1)}).strict()}).strict().optional()
|
||||
}).strict().superRefine((value, ctx) => {
|
||||
if (value.score_threshold_enabled && value.score_threshold === undefined) ctx.addIssue({code:'custom',message:'Score threshold is required'})
|
||||
if (value.reranking_enable && (!value.reranking_mode || (value.reranking_mode === 'reranking_model' ? !value.reranking_model : !value.weights))) ctx.addIssue({code:'custom',message:'Complete reranking configuration is required'})
|
||||
})
|
||||
export const externalKnowledgeProviderConfigSchema = z.union([
|
||||
z.object({provider:z.literal('dify'),useDatasetDefaults:z.literal(true)}).strict(),
|
||||
z.object({provider:z.literal('dify'),useDatasetDefaults:z.literal(false),retrievalModel}).strict(),
|
||||
z.object({provider:z.literal('fastgpt'),searchMode:z.enum(['embedding','fullTextRecall','mixedRecall']),tokenLimit:z.number().int().min(1).max(30000),similarity:z.number().min(0).max(1),usingRerank:z.boolean()}).strict(),
|
||||
z.object({provider:z.literal('ragflow'),similarityThreshold:z.number().min(0).max(1),vectorSimilarityWeight:z.number().min(0).max(1),knnTopK:z.number().int().min(1).max(2048),useKg:z.boolean(),includeKnowledgeCompilation:z.boolean()}).strict()
|
||||
])
|
||||
export type ExternalKnowledgeProviderConfig = z.infer<typeof externalKnowledgeProviderConfigSchema>
|
||||
export const externalKnowledgeInstanceSaveInputSchema = z.object({
|
||||
id:id.optional(),name:z.string().trim().min(1).max(512),provider:externalKnowledgeProviderSchema,
|
||||
baseUrl:z.string().trim().url().max(8192).refine((value)=>{const url=new URL(value);return ['http:','https:'].includes(url.protocol)&&!url.username&&!url.password&&!url.search&&!url.hash},'Invalid service URL'),
|
||||
enabled:z.boolean(),credential:z.discriminatedUnion('action',[z.object({action:z.literal('keep')}).strict(),z.object({action:z.literal('clear')}).strict(),z.object({action:z.literal('replace'),value:z.string().trim().min(1).max(16384)}).strict()])
|
||||
}).strict()
|
||||
export type ExternalKnowledgeInstanceSaveInput = z.infer<typeof externalKnowledgeInstanceSaveInputSchema>
|
||||
export const externalKnowledgeInstanceInputSchema = z.object({instanceId:id}).strict()
|
||||
export const externalKnowledgeInstanceEnabledInputSchema = externalKnowledgeInstanceInputSchema.extend({enabled:z.boolean()})
|
||||
export const externalKnowledgeCatalogListInputSchema = externalKnowledgeInstanceInputSchema.extend({page:z.number().int().min(1).max(500).optional(),pageSize:z.number().int().min(1).max(100).optional(),parentId:id.nullable().optional(),search:z.string().trim().max(512).optional()})
|
||||
export const externalKnowledgeCatalogGetInputSchema = externalKnowledgeInstanceInputSchema.extend({remoteKnowledgeBaseId:id})
|
||||
export const externalKnowledgeBindingTestInputSchema = externalKnowledgeCatalogGetInputSchema.extend({commonConfig:externalKnowledgeCommonConfigSchema,providerConfig:externalKnowledgeProviderConfigSchema,testQuery:z.string().trim().min(1).max(4000)})
|
||||
export const externalKnowledgeBindingSaveInputSchema = externalKnowledgeBindingTestInputSchema.extend({name:z.string().trim().min(1).max(512),description:z.string().max(4000).optional(),remoteName:z.string().trim().min(1).max(512)})
|
||||
export const externalKnowledgeBindingUpdateInputSchema = externalKnowledgeBindingSaveInputSchema.extend({knowledgeBaseId:id})
|
||||
export type ExternalKnowledgeBindingTestInput = z.infer<typeof externalKnowledgeBindingTestInputSchema>
|
||||
export type ExternalKnowledgeBindingSaveInput = z.infer<typeof externalKnowledgeBindingSaveInputSchema>
|
||||
export type ExternalKnowledgeInstanceSummary = {id:string;name:string;provider:ExternalKnowledgeProvider;baseUrl:string;enabled:boolean;credentialStatus:'configured'|'missing'|'unavailable';probeStatus:'untested'|'catalog-ready'|'healthy'|'list-restricted'|'auth-failed'|'unreachable'|'failed';bindingCount:number;lastTestedAt?:string;lastErrorCode?:string}
|
||||
export type ExternalKnowledgeCatalogItem = {id:string;name:string;description?:string;kind?:'dataset'|'folder';graphEnabled?:boolean;knowledgeCompilationEnabled?:boolean}
|
||||
export type ExternalKnowledgeCatalogPage = {items:ExternalKnowledgeCatalogItem[];total?:number;hasMore:boolean}
|
||||
export type ExternalKnowledgeRemoteResult = {documentTitle:string;sourceDisplayName:string;snippet:string;providerScore?:number;providerScores?:{type:string;value:number;index?:number}[];remoteDocumentId?:string;remoteChunkId?:string;location?:string;sourceUrl?:string}
|
||||
export type ExternalKnowledgeTestResult = {results:ExternalKnowledgeRemoteResult[];durationMs:number}
|
||||
export type ExternalKnowledgeBinding = {knowledgeBaseId:string;instanceId:string;provider:ExternalKnowledgeProvider;remoteKnowledgeBaseId:string;remoteName:string;commonConfig:ExternalKnowledgeCommonConfig;providerConfig:ExternalKnowledgeProviderConfig;lastVerifiedAt:string}
|
||||
export const externalKnowledgeLocatorSchema = z.object({kind:z.literal('external'),provider:externalKnowledgeProviderSchema,instanceId:id,remoteKnowledgeBaseId:id,remoteDocumentId:id.optional(),remoteChunkId:id.optional(),providerScore:z.number().finite().optional(),providerScores:z.array(z.object({type:z.string().max(128),value:z.number().finite(),index:z.number().int().optional()}).strict()).max(20).optional(),location:z.string().max(8192).optional(),sourceUrl:z.string().max(8192).optional()}).strict()
|
||||
export type ExternalKnowledgeLocator = z.infer<typeof externalKnowledgeLocatorSchema>
|
||||
@@ -1,4 +1,14 @@
|
||||
export const ipcChannels = {
|
||||
externalInstancesList: 'knowledge:external-instances:list',
|
||||
externalInstancesSave: 'knowledge:external-instances:save',
|
||||
externalInstancesTest: 'knowledge:external-instances:test',
|
||||
externalInstancesSetEnabled: 'knowledge:external-instances:set-enabled',
|
||||
externalInstancesDelete: 'knowledge:external-instances:delete',
|
||||
externalCatalogList: 'knowledge:external-catalog:list',
|
||||
externalCatalogGet: 'knowledge:external-catalog:get',
|
||||
externalBindingsCreate: 'knowledge:external-bindings:create',
|
||||
externalBindingsUpdate: 'knowledge:external-bindings:update',
|
||||
externalRetrievalTest: 'knowledge:external-retrieval:test',
|
||||
appInfo: 'app:get-info',
|
||||
appShow: 'app:show',
|
||||
appHide: 'app:hide',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { externalKnowledgeLocatorSchema } from './external-knowledge-contracts'
|
||||
import {
|
||||
defaultKnowledgeOntologySettings,
|
||||
knowledgeOntologySettingsSchema
|
||||
@@ -148,20 +149,21 @@ const channelScoresSchema = z
|
||||
export const knowledgeRetrievalResultSchema = z
|
||||
.object({
|
||||
knowledgeBaseId: idSchema,
|
||||
documentId: idSchema,
|
||||
sourceId: idSchema,
|
||||
chunkId: idSchema,
|
||||
documentId: idSchema.optional(),
|
||||
sourceId: idSchema.optional(),
|
||||
chunkId: idSchema.optional(),
|
||||
external: externalKnowledgeLocatorSchema.optional(),
|
||||
parentChunkId: idSchema.optional(),
|
||||
documentTitle: z.string().max(512),
|
||||
sourceDisplayName: z.string().max(512),
|
||||
sourceType: z.enum(['file', 'directory', 'url']),
|
||||
sourceType: z.enum(['file', 'directory', 'url']).optional(),
|
||||
heading: z.string().max(512).optional(),
|
||||
location: z.string().max(8_192).optional(),
|
||||
snippet: z.string().max(8_000),
|
||||
relevance: z.number().finite().min(0).max(1),
|
||||
relevance: z.number().finite().min(0).max(1).optional(),
|
||||
rank: z.number().int().positive().max(20),
|
||||
preRerankRank: optionalRankSchema,
|
||||
channels: z.array(knowledgeRetrievalChannelSchema).min(1).max(4),
|
||||
channels: z.array(knowledgeRetrievalChannelSchema).max(4),
|
||||
scores: channelScoresSchema
|
||||
})
|
||||
.strict()
|
||||
@@ -200,6 +202,8 @@ const channelTimingSchema = z
|
||||
|
||||
export const knowledgeRetrievalDiagnosticsSchema = z
|
||||
.object({
|
||||
external: z.object({provider:z.enum(['dify','fastgpt','ragflow']),instanceId:z.string(),remoteKnowledgeBaseId:z.string()}).strict().optional(),
|
||||
failure: z.string().max(500).optional(),
|
||||
requestedChannels: z.array(knowledgeRetrievalChannelSchema).max(4),
|
||||
usedChannels: z.array(knowledgeRetrievalChannelSchema).max(4),
|
||||
degradedChannels: z
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { KnowledgeSearchReference } from './contracts'
|
||||
import { stripKnowledgeHighlightTags } from './knowledge-text'
|
||||
import type { KnowledgeRetrievalResponse } from './knowledge-contracts'
|
||||
|
||||
export function knowledgeReferenceKey(reference: KnowledgeSearchReference): string {
|
||||
const external = reference.external
|
||||
return JSON.stringify([
|
||||
reference.libraryId, reference.documentId, reference.chunkId,
|
||||
external?.provider, external?.instanceId, external?.remoteKnowledgeBaseId,
|
||||
external?.remoteDocumentId, external?.remoteChunkId,
|
||||
external?.location ?? reference.locator, external?.sourceUrl, reference.snippet
|
||||
])
|
||||
}
|
||||
|
||||
export function toKnowledgeReference(
|
||||
result: KnowledgeRetrievalResponse['results'][number], libraryName: string
|
||||
): KnowledgeSearchReference {
|
||||
return {
|
||||
libraryId: result.knowledgeBaseId, libraryName: libraryName.slice(0, 200),
|
||||
documentId: result.documentId, chunkId: result.chunkId,
|
||||
documentName: result.documentTitle.slice(0, 500), sourceName: result.sourceDisplayName.slice(0, 500),
|
||||
locator: (result.external?.location ?? result.location)?.slice(0, 1000),
|
||||
snippet: stripKnowledgeHighlightTags(result.snippet).slice(0, 16000),
|
||||
rank: result.rank, external: result.external,
|
||||
score: result.external ? undefined : result.scores.fusedScore,
|
||||
lexicalRank: result.scores.ftsRank, vectorRank: result.scores.vectorRank,
|
||||
graphRank: result.scores.graphRank, similarity: result.scores.vectorSimilarity,
|
||||
retrievalChannels: result.channels
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,7 @@ export const remoteSemanticTranscriptPageResultSchema = z
|
||||
acknowledgedSequence: agentSequenceSchema,
|
||||
state: remoteOwnedPromptStateSchema,
|
||||
sessionId: agentIdentifierSchema.optional(),
|
||||
pendingQuestions: z.array(jsonValueSchema).optional(),
|
||||
hasMore: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod'
|
||||
import { agentQuestionResponseSchema } from './contracts'
|
||||
|
||||
export const remoteQuestionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
sessionID: z.string().min(1),
|
||||
questions: z.array(z.object({
|
||||
header: z.string(),
|
||||
question: z.string(),
|
||||
options: z.array(z.object({ label: z.string(), description: z.string() })),
|
||||
multiple: z.boolean().default(false),
|
||||
custom: z.boolean().default(true)
|
||||
})).min(1),
|
||||
tool: z.object({ messageID: z.string(), callID: z.string() }).optional()
|
||||
})
|
||||
|
||||
export const remoteQuestionResponseSchema = agentQuestionResponseSchema.extend({
|
||||
bindingId: z.string().min(1),
|
||||
operationId: z.string().min(1)
|
||||
}).strict()
|
||||
|
||||
export type RemoteQuestionResponse = z.infer<typeof remoteQuestionResponseSchema>
|
||||
Reference in New Issue
Block a user