refactor(admin): AI 对话收敛为统一 ui.artifact 协议,移除 legacy 事件处理

This commit is contained in:
2026-08-08 15:05:20 +08:00
parent b37ae9471e
commit 57639a3869
4 changed files with 32 additions and 154 deletions

View File

@@ -44,7 +44,6 @@ const toolLabels: Record<string, string> = {
search_bills: '查询账单', search_bills: '查询账单',
get_dashboard_stats: '读取经营概览', get_dashboard_stats: '读取经营概览',
render_form: '生成表单', render_form: '生成表单',
render_review: '生成导入预览',
render_chart: '生成图表', render_chart: '生成图表',
start_import_wizard: '生成导入向导', start_import_wizard: '生成导入向导',
create_student: '创建学生', create_student: '创建学生',

View File

@@ -123,34 +123,6 @@ describe('AI chat SSE message reducer', () => {
expect(message.id).toBe(9); expect(message.id).toBe(9);
}); });
it('ignores legacy ui.form events and only consumes ui.artifact (过渡期双发)', () => {
let message = reduceAiSseMessage(undefined, {
event: 'ui.form',
data: JSON.stringify({ messageId: 8, form: { id: 'form-1', title: '新增学生', fields: [] } }),
});
// 旧事件不再写入 legacy 列表
expect(message.forms).toHaveLength(0);
expect(message.uiArtifacts).toHaveLength(0);
message = reduceAiSseMessage(message, {
event: 'ui.artifact',
data: JSON.stringify({
messageId: 8,
artifact: {
id: 'form-1',
type: 'form',
status: 'pending',
messageId: 8,
conversationId: 3,
payload: { id: 'form-1', title: '新增学生', fields: [] },
},
}),
});
expect(message.uiArtifacts).toHaveLength(1);
expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' });
});
it('merges ui.artifact events into uiArtifacts by id', () => { it('merges ui.artifact events into uiArtifacts by id', () => {
let message = reduceAiSseMessage(undefined, { let message = reduceAiSseMessage(undefined, {
event: 'ui.artifact', event: 'ui.artifact',
@@ -210,44 +182,6 @@ describe('AI chat SSE message reducer', () => {
expect(message.forms?.[0].id).toBe('form-9'); expect(message.forms?.[0].id).toBe('form-9');
}); });
it('ignores legacy ui.review events and only consumes ui.artifact', () => {
const review = {
id: 'review-1',
title: '开学导入',
summary: '来自报名 Excel',
status: 'pending',
sections: [
{
key: 'students',
type: 'students',
title: '学生',
kind: 'table',
columns: [
{ key: 'name', title: '姓名' },
{ key: 'phone', title: '手机号' },
],
rows: [{ name: '张三', phone: '13800138000' }],
issues: [],
},
],
};
let message = reduceAiSseMessage(undefined, {
event: 'ui.review',
data: JSON.stringify({ messageId: 8, review }),
});
message = reduceAiSseMessage(message, {
event: 'ui.review',
data: JSON.stringify({
messageId: 8,
review: { ...review, status: 'submitted', resultSummary: '{"students":{"created":1}}' },
}),
});
// 旧事件不再写入 legacy 列表
expect(message.reviews).toBeUndefined();
expect(message.uiArtifacts).toHaveLength(0);
});
it('shows model retrying state and clears it when content starts', () => { it('shows model retrying state and clears it when content starts', () => {
let message = reduceAiSseMessage(undefined, { let message = reduceAiSseMessage(undefined, {
event: 'model.retrying', event: 'model.retrying',
@@ -299,37 +233,6 @@ describe('AI chat SSE message reducer', () => {
expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' }); expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
}); });
it('ignores legacy ui.chart events and only consumes ui.artifact', () => {
const chart = {
id: 'chart-1',
title: '各班级人数',
chartType: 'bar',
columns: [
{ key: 'className', title: '班级' },
{ key: 'count', title: '人数' },
],
rows: [
{ className: '一班', count: 20 },
{ className: '二班', count: 15 },
],
};
let message = reduceAiSseMessage(undefined, {
event: 'ui.chart',
data: JSON.stringify({ messageId: 8, chart }),
});
message = reduceAiSseMessage(message, {
event: 'ui.chart',
data: JSON.stringify({
messageId: 8,
chart: { ...chart, id: 'chart-2', title: '女生人数' },
}),
});
// 旧事件不再写入 legacy 列表
expect(message.charts).toBeUndefined();
expect(message.uiArtifacts).toHaveLength(0);
});
it('restores persisted charts from message.completed metadata', () => { it('restores persisted charts from message.completed metadata', () => {
const message = reduceAiSseMessage(undefined, { const message = reduceAiSseMessage(undefined, {
event: 'message.completed', event: 'message.completed',
@@ -457,15 +360,16 @@ describe('AI chat SSE message reducer', () => {
} }
}); });
it('routes submit-time ui.review to the original message instead of the streaming one', () => { it('routes ui.artifact targeting another message to the external handler', () => {
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
const onExternalReview = vi.fn(); const onExternalArtifact = vi.fn();
provider.onExternalReview = onExternalReview; provider.onExternalArtifact = onExternalArtifact;
const review = { const artifact = {
id: 'review-1', id: 'artifact-1',
title: '批量导入', type: 'form',
status: 'submitted', status: 'submitted',
sections: [], messageId: 12,
payload: { id: 'form-1', title: '批量导入', status: 'submitted' },
}; };
const origin = { const origin = {
id: 13, id: 13,
@@ -474,31 +378,37 @@ describe('AI chat SSE message reducer', () => {
reasoningContent: '', reasoningContent: '',
toolRuns: [], toolRuns: [],
attachments: [], attachments: [],
reviews: [], uiArtifacts: [],
}; };
const next = provider.transformMessage({ const next = provider.transformMessage({
originMessage: origin, originMessage: origin,
chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) }, chunk: { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact }) },
status: 'updating', status: 'updating',
chunks: [], chunks: [],
responseHeaders: {} as Headers, responseHeaders: {} as Headers,
}); });
expect(onExternalReview).toHaveBeenCalledWith(12, review); expect(onExternalArtifact).toHaveBeenCalledWith(12, artifact);
expect(next).toBe(origin); expect(next).toBe(origin);
expect(next.reviews ?? []).toHaveLength(0); expect(next.uiArtifacts ?? []).toHaveLength(0);
}); });
it('routes ui.review without an origin message to the external handler', () => { it('routes ui.artifact without an origin message to the external handler', () => {
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
const onExternalReview = vi.fn(); const onExternalArtifact = vi.fn();
provider.onExternalReview = onExternalReview; provider.onExternalArtifact = onExternalArtifact;
const next = provider.transformMessage({ const next = provider.transformMessage({
chunk: { chunk: {
event: 'ui.review', event: 'ui.artifact',
data: JSON.stringify({ data: JSON.stringify({
messageId: 12, messageId: 12,
review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] }, artifact: {
id: 'artifact-1',
type: 'review',
status: 'submitted',
messageId: 12,
payload: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] },
},
}), }),
}, },
status: 'updating', status: 'updating',
@@ -506,11 +416,11 @@ describe('AI chat SSE message reducer', () => {
responseHeaders: {} as Headers, responseHeaders: {} as Headers,
}); });
expect(onExternalReview).toHaveBeenCalledWith( expect(onExternalArtifact).toHaveBeenCalledWith(
12, 12,
expect.objectContaining({ id: 'review-1' }), expect.objectContaining({ id: 'artifact-1' }),
); );
expect(next.reviews ?? []).toHaveLength(0); expect(next.uiArtifacts ?? []).toHaveLength(0);
}); });
it('tolerates non-JSON event data', () => { it('tolerates non-JSON event data', () => {

View File

@@ -191,32 +191,6 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
this.onExternalArtifact?.(payload.messageId, payload.artifact); this.onExternalArtifact?.(payload.messageId, payload.artifact);
return info.originMessage ?? emptyAssistant(); return info.originMessage ?? emptyAssistant();
} }
if (
event === 'ui.form' &&
payload.form &&
typeof payload.messageId === 'number' &&
info.originMessage?.id !== payload.messageId
) {
this.onExternalArtifact?.(payload.messageId, {
id: payload.form.id,
type: 'form',
status: payload.form.status ?? 'pending',
messageId: payload.messageId,
payload: payload.form,
});
return info.originMessage ?? emptyAssistant();
}
if (
event === 'ui.review' &&
payload.review &&
typeof payload.messageId === 'number' &&
info.originMessage?.id !== payload.messageId
) {
// The submitted review belongs to the original assistant message;
// do not merge it into the message currently being streamed.
this.onExternalReview?.(payload.messageId, payload.review);
return info.originMessage ?? emptyAssistant();
}
return reduceAiSseMessage(info.originMessage, info.chunk); return reduceAiSseMessage(info.originMessage, info.chunk);
} }
} }

View File

@@ -25,10 +25,7 @@ export interface AiSsePayload {
summary?: string | null; summary?: string | null;
durationMs?: number | null; durationMs?: number | null;
attachment?: AiAttachment; attachment?: AiAttachment;
form?: AiFormSchema;
artifact?: AiArtifactSchema; artifact?: AiArtifactSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
wizard?: unknown; wizard?: unknown;
retry?: AiModelRetryInfo; retry?: AiModelRetryInfo;
message?: message?:
@@ -109,22 +106,21 @@ function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRu
function applyMessagePayload( function applyMessagePayload(
message: AiChatMessage, message: AiChatMessage,
nested: AiSsePayload['message'], nested: AiSsePayload['message'],
payload: AiSsePayload,
): void { ): void {
if (typeof nested !== 'object' || nested === null) return; if (typeof nested !== 'object' || nested === null) return;
// 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart // 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart
// 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。 // 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。
message.forms = mergeById<AiFormSchema>( message.forms = mergeById<AiFormSchema>(
message.forms, message.forms,
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, nested.metadata?.a2uiForm as AiFormSchema | undefined,
); );
message.reviews = mergeById<AiReviewSchema>( message.reviews = mergeById<AiReviewSchema>(
message.reviews, message.reviews,
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, nested.metadata?.a2uiReview as AiReviewSchema | undefined,
); );
message.charts = mergeById<AiChartSchema>( message.charts = mergeById<AiChartSchema>(
message.charts, message.charts,
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined,
); );
const artifacts = nested.metadata?.uiArtifacts; const artifacts = nested.metadata?.uiArtifacts;
if (Array.isArray(artifacts)) { if (Array.isArray(artifacts)) {
@@ -152,7 +148,7 @@ export function reduceAiSseMessage(
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments; message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload); applyMessagePayload(message, nested);
} else if (event === 'reasoning.delta') { } else if (event === 'reasoning.delta') {
message.retrying = null; message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
@@ -162,8 +158,7 @@ export function reduceAiSseMessage(
} else if (event === 'model.retrying' && payload.retry) { } else if (event === 'model.retrying' && payload.retry) {
message.retrying = payload.retry; message.retrying = payload.retry;
} else if (event === 'ui.artifact' && payload.artifact) { } else if (event === 'ui.artifact' && payload.artifact) {
// 统一 artifact 事件:后端过渡期仍双发 ui.form/ui.review/ui.chart // 统一 artifact 事件legacy 列表由渲染层从 uiArtifacts 派生。
// 前端只消费 ui.artifactlegacy 列表由渲染层从 uiArtifacts 派生。
mergeArtifactIntoMessage(message, payload.artifact); mergeArtifactIntoMessage(message, payload.artifact);
} else if (event === 'ui.import_wizard' && payload.wizard) { } else if (event === 'ui.import_wizard' && payload.wizard) {
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
@@ -185,7 +180,7 @@ export function reduceAiSseMessage(
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments; message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload); applyMessagePayload(message, nested);
message.retrying = null; message.retrying = null;
} else if (event === 'message.cancelled') { } else if (event === 'message.cancelled') {
message.id = payload.messageId ?? message.id; message.id = payload.messageId ?? message.id;