feat: 接入一库一工具知识检索

- 编译知识库英文运行名、描述、检索配置和独立 Registration

- 统一最终分数阈值并保持模型上下文、检索事件与引用一致

- 完善 AG-UI 知识库检索运行态与完成态展示
This commit is contained in:
2026-08-29 15:42:21 +08:00
parent 30904f1503
commit 588e810c51
14 changed files with 832 additions and 44 deletions

View File

@@ -2,6 +2,7 @@ import { EventType } from '@ag-ui/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EasyFlowAguiClient, EasyFlowAguiProjectionError } from './client';
import { easyFlowAguiCustomEvent } from './custom-events';
import { isRetryableAguiTransportError } from './reconnect';
vi.mock('#/api/request', () => ({
@@ -276,6 +277,69 @@ describe('easyFlowAguiClient', () => {
expect(received.at(-1)).toBe(EventType.RUN_FINISHED);
});
it('yields a paint opportunity after knowledge retrieval starts', async () => {
vi.useFakeTimers();
let paintCallback: FrameRequestCallback | undefined;
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
paintCallback = callback;
return 1;
}),
);
vi.stubGlobal(
'fetch',
vi.fn(async () =>
sse([
{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED },
{
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
type: EventType.CUSTOM,
value: {
status: 'running',
statusKey: 'knowledge-retrieval',
},
},
{
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
type: EventType.CUSTOM,
value: {
status: 'done',
statusKey: 'knowledge-retrieval',
},
},
{
runId: 'run-1',
threadId: '101',
type: EventType.RUN_FINISHED,
},
]),
),
);
const receivedStatuses: string[] = [];
const runPromise = new EasyFlowAguiClient().run({
onEvent: (event) => {
if (event.type === EventType.CUSTOM) {
const value = event.value as Record<string, unknown>;
receivedStatuses.push(String(value.status));
}
},
threadId: '101',
url: '/api/v1/agent/1/agui/run',
userMessage: { content: '几点退房', id: 'user-1', role: 'user' },
});
await vi.advanceTimersByTimeAsync(0);
expect(receivedStatuses).toEqual(['running']);
paintCallback?.(0);
await vi.advanceTimersByTimeAsync(0);
await runPromise;
expect(receivedStatuses).toEqual(['running', 'done']);
});
it('replays a completed run from the server journal after refresh', async () => {
vi.stubGlobal(
'fetch',

View File

@@ -5,6 +5,8 @@ import { events } from 'fetch-event-stream';
import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
import { easyFlowAguiCustomEvent } from './custom-events';
export interface EasyFlowAguiRunOptions {
forwardedProps?: Record<string, unknown>;
onCursor?: (cursor: number) => void;
@@ -101,6 +103,31 @@ function waitForToolStartPaint(): Promise<void> {
});
}
/**
* 判断事件是否开启了需要即时呈现的工具执行状态。
*
* @param event AG-UI 事件
* @returns 标准工具开始或知识库检索开始时为 true
*/
function startsVisibleToolExecution(event: AguiEvent) {
if (event.type === EventType.TOOL_CALL_START) {
return true;
}
if (
event.type !== EventType.CUSTOM ||
event.name !== easyFlowAguiCustomEvent.knowledgeRetrievalStatus
) {
return false;
}
const value =
event.value &&
typeof event.value === 'object' &&
!Array.isArray(event.value)
? (event.value as Record<string, unknown>)
: {};
return String(value.status || '').toLowerCase() === 'running';
}
/**
* EasyFlow 的无头 AG-UI 运行客户端。
*
@@ -174,7 +201,7 @@ export class EasyFlowAguiClient {
) {
terminalReceived = true;
}
if (event.type === EventType.TOOL_CALL_START) {
if (startsVisibleToolExecution(event as AguiEvent)) {
await waitForToolStartPaint();
}
}
@@ -233,7 +260,7 @@ export class EasyFlowAguiClient {
if (Number.isSafeInteger(cursor) && cursor > 0) {
options.onCursor?.(cursor);
}
if (event.type === EventType.TOOL_CALL_START) {
if (startsVisibleToolExecution(event as AguiEvent)) {
await waitForToolStartPaint();
}
}

View File

@@ -336,6 +336,76 @@ describe('aG-UI wire contract and timeline projection', () => {
).toBe(true);
});
it('merges knowledge retrieval tool and status events within one turn', () => {
const items: ChatTimelineItem[] = [];
const state = createAguiTimelineProjectionState();
const events = [
{
toolCallId: 'tool-faq-1',
toolCallName: 'retrieve_knowledge_homeinn_faq',
type: EventType.TOOL_CALL_START,
},
{
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
type: EventType.CUSTOM,
value: {
label: '已检索知识库',
status: 'done',
statusKey: 'knowledge-retrieval',
},
},
{
content: 'Retrieved 1 relevant document(s)',
messageId: 'tool-result-faq-1',
role: 'tool',
toolCallId: 'tool-faq-1',
type: EventType.TOOL_CALL_RESULT,
},
].map((event) => EventSchemas.parse(event));
for (const event of events) {
applyAguiEventToTimeline(items, event, { roundId: 'round-faq' }, state);
}
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
label: '已检索知识库',
roundId: 'round-faq',
status: 'done',
statusKey: 'knowledge-retrieval:round-faq',
type: 'status',
});
});
it('projects failed knowledge retrieval without exposing tool details', () => {
const items: ChatTimelineItem[] = [];
applyAguiEventToTimeline(
items,
EventSchemas.parse({
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
type: EventType.CUSTOM,
value: {
internalError: 'private stack',
status: 'error',
statusKey: 'knowledge-retrieval',
},
}),
{ roundId: 'round-failed-knowledge' },
createAguiTimelineProjectionState(),
);
expect(items).toEqual([
expect.objectContaining({
label: '知识库检索失败',
status: 'error',
statusKey: 'knowledge-retrieval:round-failed-knowledge',
tone: 'danger',
type: 'status',
}),
]);
expect(JSON.stringify(items)).not.toContain('private stack');
});
it('projects Skill invocation status in place through the strict public fields', () => {
const items: ChatTimelineItem[] = [];
const state = createAguiTimelineProjectionState();

View File

@@ -3,6 +3,7 @@ import type {
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineSkillInvocationStatus,
ChatTimelineStatusStatus,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
@@ -131,6 +132,13 @@ function asyncToolStatus(
return 'running';
}
function knowledgeRetrievalStatus(value: unknown): ChatTimelineStatusStatus {
const status = asText(value).trim().toLowerCase();
if (status === 'running') return 'running';
if (status === 'error' || status === 'failed') return 'error';
return 'done';
}
function statusKey(
payload: Record<string, unknown>,
options: AguiTimelineProjectionOptions,
@@ -275,7 +283,7 @@ function applyCustomEvent(
if (event.name === easyFlowAguiCustomEvent.knowledgeRetrievalStatus) {
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
items,
asText(payload.status).toLowerCase() === 'running' ? 'running' : 'done',
knowledgeRetrievalStatus(payload.status),
statusKey(payload, options, 'knowledge-retrieval'),
turnMetadata,
);