feat: 接入一库一工具知识检索
- 编译知识库英文运行名、描述、检索配置和独立 Registration - 统一最终分数阈值并保持模型上下文、检索事件与引用一致 - 完善 AG-UI 知识库检索运行态与完成态展示
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -54,20 +54,29 @@ function isHiddenToolName(toolName?: string) {
|
||||
const normalizedName = normalizeToolName(toolName);
|
||||
return (
|
||||
normalizedName === 'retrieve_knowledge' ||
|
||||
normalizedName.startsWith('retrieve_knowledge_') ||
|
||||
normalizedName === 'context_reload' ||
|
||||
normalizedName === '__fragment__'
|
||||
);
|
||||
}
|
||||
|
||||
function isKnowledgeRetrievalToolName(toolName?: string) {
|
||||
return normalizeToolName(toolName) === 'retrieve_knowledge';
|
||||
const normalizedName = normalizeToolName(toolName);
|
||||
return (
|
||||
normalizedName === 'retrieve_knowledge' ||
|
||||
normalizedName.startsWith('retrieve_knowledge_')
|
||||
);
|
||||
}
|
||||
|
||||
function isBlankToolName(toolName?: string) {
|
||||
return !normalizeToolName(toolName);
|
||||
}
|
||||
|
||||
function knowledgeRetrievalStatusKey(statusKey?: string) {
|
||||
function knowledgeRetrievalStatusKey(statusKey?: string, roundId?: string) {
|
||||
const normalizedRoundId = normalizeText(roundId).trim();
|
||||
if (normalizedRoundId) {
|
||||
return `knowledge-retrieval:${normalizedRoundId}`;
|
||||
}
|
||||
return normalizeText(statusKey).trim() || 'knowledge-retrieval';
|
||||
}
|
||||
|
||||
@@ -659,12 +668,18 @@ export const ChatTimelineBuilder = {
|
||||
metadata?: ChatTimelineTurnMetadata,
|
||||
) {
|
||||
finishAssistantMessage(items, false, metadata?.roundId);
|
||||
let label = '已检索知识库';
|
||||
if (status === 'running') {
|
||||
label = '正在检索知识库';
|
||||
} else if (status === 'error') {
|
||||
label = '知识库检索失败';
|
||||
}
|
||||
upsertStatus(items, {
|
||||
...metadata,
|
||||
label: status === 'running' ? '正在检索知识库' : '已检索知识库',
|
||||
label,
|
||||
status,
|
||||
statusKey: knowledgeRetrievalStatusKey(statusKey),
|
||||
tone: 'muted',
|
||||
statusKey: knowledgeRetrievalStatusKey(statusKey, metadata?.roundId),
|
||||
tone: status === 'error' ? 'danger' : 'muted',
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user