diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index e834432..554d0e1 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -44,7 +44,6 @@ const toolLabels: Record = { search_bills: '查询账单', get_dashboard_stats: '读取经营概览', render_form: '生成表单', - render_review: '生成导入预览', render_chart: '生成图表', start_import_wizard: '生成导入向导', create_student: '创建学生', diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index 00251b7..dcd28da 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -123,34 +123,6 @@ describe('AI chat SSE message reducer', () => { 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', () => { let message = reduceAiSseMessage(undefined, { event: 'ui.artifact', @@ -210,44 +182,6 @@ describe('AI chat SSE message reducer', () => { 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', () => { let message = reduceAiSseMessage(undefined, { event: 'model.retrying', @@ -299,37 +233,6 @@ describe('AI chat SSE message reducer', () => { 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', () => { const message = reduceAiSseMessage(undefined, { 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 onExternalReview = vi.fn(); - provider.onExternalReview = onExternalReview; - const review = { - id: 'review-1', - title: '批量导入', + const onExternalArtifact = vi.fn(); + provider.onExternalArtifact = onExternalArtifact; + const artifact = { + id: 'artifact-1', + type: 'form', status: 'submitted', - sections: [], + messageId: 12, + payload: { id: 'form-1', title: '批量导入', status: 'submitted' }, }; const origin = { id: 13, @@ -474,31 +378,37 @@ describe('AI chat SSE message reducer', () => { reasoningContent: '', toolRuns: [], attachments: [], - reviews: [], + uiArtifacts: [], }; const next = provider.transformMessage({ originMessage: origin, - chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) }, + chunk: { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact }) }, status: 'updating', chunks: [], responseHeaders: {} as Headers, }); - expect(onExternalReview).toHaveBeenCalledWith(12, review); + expect(onExternalArtifact).toHaveBeenCalledWith(12, artifact); 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 onExternalReview = vi.fn(); - provider.onExternalReview = onExternalReview; + const onExternalArtifact = vi.fn(); + provider.onExternalArtifact = onExternalArtifact; const next = provider.transformMessage({ chunk: { - event: 'ui.review', + event: 'ui.artifact', data: JSON.stringify({ 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', @@ -506,11 +416,11 @@ describe('AI chat SSE message reducer', () => { responseHeaders: {} as Headers, }); - expect(onExternalReview).toHaveBeenCalledWith( + expect(onExternalArtifact).toHaveBeenCalledWith( 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', () => { diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index f37e3b5..024dfcc 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -191,32 +191,6 @@ export class GongxueAiChatProvider extends AbstractChatProvider< this.onExternalArtifact?.(payload.messageId, payload.artifact); 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); } } diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts index 69ed724..57d65fc 100644 --- a/apps/admin/src/components/AiChat/sseReducer.ts +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -25,10 +25,7 @@ export interface AiSsePayload { summary?: string | null; durationMs?: number | null; attachment?: AiAttachment; - form?: AiFormSchema; artifact?: AiArtifactSchema; - review?: AiReviewSchema; - chart?: AiChartSchema; wizard?: unknown; retry?: AiModelRetryInfo; message?: @@ -109,22 +106,21 @@ function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRu function applyMessagePayload( message: AiChatMessage, nested: AiSsePayload['message'], - payload: AiSsePayload, ): void { if (typeof nested !== 'object' || nested === null) return; // 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart, // 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。 message.forms = mergeById( message.forms, - (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, + nested.metadata?.a2uiForm as AiFormSchema | undefined, ); message.reviews = mergeById( message.reviews, - (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, + nested.metadata?.a2uiReview as AiReviewSchema | undefined, ); message.charts = mergeById( message.charts, - (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, + nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined, ); const artifacts = nested.metadata?.uiArtifacts; if (Array.isArray(artifacts)) { @@ -152,7 +148,7 @@ export function reduceAiSseMessage( message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); + applyMessagePayload(message, nested); } else if (event === 'reasoning.delta') { message.retrying = null; message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; @@ -162,8 +158,7 @@ export function reduceAiSseMessage( } else if (event === 'model.retrying' && payload.retry) { message.retrying = payload.retry; } else if (event === 'ui.artifact' && payload.artifact) { - // 统一 artifact 事件:后端过渡期仍双发 ui.form/ui.review/ui.chart, - // 前端只消费 ui.artifact,legacy 列表由渲染层从 uiArtifacts 派生。 + // 统一 artifact 事件;legacy 列表由渲染层从 uiArtifacts 派生。 mergeArtifactIntoMessage(message, payload.artifact); } else if (event === 'ui.import_wizard' && payload.wizard) { message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; @@ -185,7 +180,7 @@ export function reduceAiSseMessage( nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); + applyMessagePayload(message, nested); message.retrying = null; } else if (event === 'message.cancelled') { message.id = payload.messageId ?? message.id;