From bcd2d1a559c135b769d690637e940ee64f6c48b4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 09:33:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor(admin):=20A2UI=20=E5=8F=8C=E8=BD=A8?= =?UTF-8?q?=E6=94=B6=E6=95=9B,=E7=BB=9F=E4=B8=80=20artifact=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mergeArtifactIntoMessage 不再派发 legacy 列表,只维护 uiArtifacts - 渲染层从 uiArtifacts 派生 forms/reviews/charts,为空时回退 legacy (历史消息兼容,老 metadata 仅 a2uiForm/a2uiReview/a2uiChart) - sseReducer 删除 ui.form/ui.review/ui.chart 旧事件分支,只消费 ui.artifact(后端过渡期双发,旧事件将被忽略) - types.ts legacy 字段标注 deprecated;契约文档同步现状 - 测试更新:旧事件忽略 + uiArtifacts 合并/恢复断言 aislop scan: 5 引擎 0 issues --- .../components/AiChat/AiMessageContent.tsx | 12 +++- .../message-mappers.integration.test.ts | 12 +++- .../AiChat/provider.integration.test.ts | 58 +++++++++--------- .../admin/src/components/AiChat/sseReducer.ts | 14 ++--- apps/admin/src/components/AiChat/types.ts | 4 ++ .../src/components/AiChat/uiArtifacts.ts | 60 ++++++++++--------- scripts/a2ui-contract.md | 17 +++--- 7 files changed, 98 insertions(+), 79 deletions(-) diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index eb85fd6..e834432 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -17,6 +17,7 @@ import { message } from '../../ui/app-message'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import { deriveCharts, deriveForms, deriveReviews } from './uiArtifacts'; import { ArtifactErrorBoundary } from './ArtifactErrorBoundary'; import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteMermaid } from './LiteMermaid'; @@ -226,6 +227,11 @@ export const AiMessageContent: React.FC = ({ const streaming = status === 'loading' || status === 'updating'; const formSubmission = message.metadata?.a2uiSubmit; const reviewSubmission = message.metadata?.a2uiReviewSubmit; + // 统一 artifact 优先,历史消息(仅 legacy 字段)回退 + const forms = deriveForms(message).length > 0 ? deriveForms(message) : (message.forms ?? []); + const reviews = + deriveReviews(message).length > 0 ? deriveReviews(message) : (message.reviews ?? []); + const charts = deriveCharts(message).length > 0 ? deriveCharts(message) : (message.charts ?? []); const sourceMeta = message.metadata?.a2uiSources; const sourceItems = Array.isArray(sourceMeta) ? sourceMeta @@ -374,7 +380,7 @@ export const AiMessageContent: React.FC = ({ onClick={(item) => void handleOpenSource(item as { url?: string })} /> )} - {(message.forms ?? []).map((form) => ( + {(forms ?? []).map((form) => ( = ({ /> ))} - {(message.reviews ?? []).map((review: AiReviewSchema) => ( + {(reviews ?? []).map((review: AiReviewSchema) => ( = ({ /> ))} - {(message.charts ?? []).map((chart: AiChartSchema) => ( + {(charts ?? []).map((chart: AiChartSchema) => ( diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 5363603..fe998ad 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -78,7 +78,7 @@ describe('AI chat history mapper', () => { expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' }); }); - it('restores uiArtifacts from message metadata and derives legacy lists', () => { + it('restores uiArtifacts from message metadata (统一协议)', () => { const mapped = mapHistoryMessage({ id: 8, role: 'assistant', @@ -120,8 +120,14 @@ describe('AI chat history mapper', () => { }); expect(mapped.message.uiArtifacts).toHaveLength(2); - expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-10', status: 'submitted' }); - expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-10', status: 'expired' }); + expect(mapped.message.uiArtifacts?.[0].payload).toMatchObject({ + id: 'form-10', + status: 'submitted', + }); + expect(mapped.message.uiArtifacts?.[1].payload).toMatchObject({ + id: 'review-10', + status: 'expired', + }); }); it('restores a persisted A2UI review from message metadata', () => { diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index a0761d0..00251b7 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -123,38 +123,35 @@ describe('AI chat SSE message reducer', () => { expect(message.id).toBe(9); }); - it('merges ui.form events into the assistant message by id', () => { - const form = { - id: 'form-1', - title: '新增学生', - submitLabel: '提交创建', - fields: [ - { name: 'name', label: '姓名', type: 'input', required: true }, - { name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] }, - ], - }; + it('ignores legacy ui.form events and only consumes ui.artifact (过渡期双发)', () => { let message = reduceAiSseMessage(undefined, { event: 'ui.form', - data: JSON.stringify({ messageId: 8, 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.form', - data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }), - }); - message = reduceAiSseMessage(message, { - event: 'ui.form', + event: 'ui.artifact', data: JSON.stringify({ messageId: 8, - form: { id: 'form-2', title: '入住确认', fields: [] }, + artifact: { + id: 'form-1', + type: 'form', + status: 'pending', + messageId: 8, + conversationId: 3, + payload: { id: 'form-1', title: '新增学生', fields: [] }, + }, }), }); - expect(message.forms).toHaveLength(2); - expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); - expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); + expect(message.uiArtifacts).toHaveLength(1); + expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' }); }); - it('merges ui.artifact events into uiArtifacts and legacy lists by id', () => { + it('merges ui.artifact events into uiArtifacts by id', () => { let message = reduceAiSseMessage(undefined, { event: 'ui.artifact', data: JSON.stringify({ @@ -185,8 +182,8 @@ describe('AI chat SSE message reducer', () => { }); expect(message.uiArtifacts).toHaveLength(2); - expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); - expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' }); + expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' }); + expect(message.uiArtifacts?.[1].payload).toMatchObject({ id: 'review-1', status: 'expired' }); }); @@ -213,7 +210,7 @@ describe('AI chat SSE message reducer', () => { expect(message.forms?.[0].id).toBe('form-9'); }); - it('merges ui.review events into the assistant message and updates by id', () => { + it('ignores legacy ui.review events and only consumes ui.artifact', () => { const review = { id: 'review-1', title: '开学导入', @@ -246,8 +243,9 @@ describe('AI chat SSE message reducer', () => { }), }); - expect(message.reviews).toHaveLength(1); - expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' }); + // 旧事件不再写入 legacy 列表 + expect(message.reviews).toBeUndefined(); + expect(message.uiArtifacts).toHaveLength(0); }); it('shows model retrying state and clears it when content starts', () => { @@ -301,7 +299,7 @@ describe('AI chat SSE message reducer', () => { expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' }); }); - it('merges ui.chart events into the assistant message by id', () => { + it('ignores legacy ui.chart events and only consumes ui.artifact', () => { const chart = { id: 'chart-1', title: '各班级人数', @@ -327,9 +325,9 @@ describe('AI chat SSE message reducer', () => { }), }); - expect(message.charts).toHaveLength(2); - expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' }); - expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' }); + // 旧事件不再写入 legacy 列表 + expect(message.charts).toBeUndefined(); + expect(message.uiArtifacts).toHaveLength(0); }); it('restores persisted charts from message.completed metadata', () => { diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts index e58a224..69ed724 100644 --- a/apps/admin/src/components/AiChat/sseReducer.ts +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -9,7 +9,7 @@ import type { AiSseChunk, AiToolRun, } from './types'; -import { mergeArtifactIntoMessage, mergeById, mergeForms } from './uiArtifacts'; +import { mergeArtifactIntoMessage, mergeById } from './uiArtifacts'; export interface AiSsePayload { messageId?: number; @@ -112,7 +112,9 @@ function applyMessagePayload( payload: AiSsePayload, ): void { if (typeof nested !== 'object' || nested === null) return; - message.forms = mergeForms( + // 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart, + // 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。 + message.forms = mergeById( message.forms, (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, ); @@ -159,13 +161,9 @@ export function reduceAiSseMessage( message.content += payload.delta ?? payload.content ?? ''; } else if (event === 'model.retrying' && payload.retry) { message.retrying = payload.retry; - } else if (event === 'ui.form' && payload.form) { - message.forms = mergeForms(message.forms, payload.form); - } else if (event === 'ui.review' && payload.review) { - message.reviews = mergeById(message.reviews, payload.review); - } else if (event === 'ui.chart' && payload.chart) { - message.charts = mergeById(message.charts, payload.chart); } else if (event === 'ui.artifact' && payload.artifact) { + // 统一 artifact 事件:后端过渡期仍双发 ui.form/ui.review/ui.chart, + // 前端只消费 ui.artifact,legacy 列表由渲染层从 uiArtifacts 派生。 mergeArtifactIntoMessage(message, payload.artifact); } else if (event === 'ui.import_wizard' && payload.wizard) { message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index aee0823..85d915c 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -171,9 +171,13 @@ export interface AiChatMessage { reasoningContent: string; toolRuns: AiToolRun[]; attachments: AiAttachment[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiForm);新数据统一走 uiArtifacts */ forms?: AiFormSchema[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiReview);新数据统一走 uiArtifacts */ reviews?: AiReviewSchema[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiChart);新数据统一走 uiArtifacts */ charts?: AiChartSchema[]; + /** 统一 A2UI 制品协议(唯一事实源) */ uiArtifacts?: AiArtifactSchema[]; replyToMessageId?: number | null; metadata?: Record | null; diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts index 32f1f59..6b90bc5 100644 --- a/apps/admin/src/components/AiChat/uiArtifacts.ts +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -25,43 +25,49 @@ export function mergeById( return next; } -export function mergeForms( - current: AiFormSchema[] | undefined, - incoming: AiFormSchema | AiFormSchema[] | undefined, -): AiFormSchema[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { - next.push(item); - } - } - return next; -} - function payloadOf(artifact: AiArtifactSchema): unknown { return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {}; } /** - * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 - * payload 来自服务端契约(表单/审阅/图表/预检/向导),按类型做单次断言。 + * 将统一 artifact 归入 uiArtifacts。 + * + * 注意:不再派发到 legacy 列表(forms/reviews/charts)——渲染层从 + * uiArtifacts 派生,legacy 字段仅保留给历史消息(metadata 中只有 + * a2uiForm/a2uiReview/a2uiChart 的老数据)作兼容读取。 */ export function mergeArtifactIntoMessage( message: AiChatMessage, artifact: AiArtifactSchema, ): AiChatMessage { message.uiArtifacts = mergeById(message.uiArtifacts, artifact); - const payload = payloadOf(artifact); - if (artifact.type === 'form') { - message.forms = mergeForms(message.forms, payload as AiFormSchema); - } else if (artifact.type === 'review') { - message.reviews = mergeById(message.reviews, payload as AiReviewSchema); - } else if (artifact.type === 'chart') { - message.charts = mergeById(message.charts, payload as AiChartSchema); - } else if (artifact.type === 'import_wizard') { - message.metadata = { ...message.metadata, a2uiImportWizard: payload }; - } + void payloadOf(artifact); return message; } + +/** + * 从 uiArtifacts 派生 legacy 列表(渲染用)。 + * 仅当 message 上没有显式 legacy 数据(历史消息)时,渲染层回退到 message.forms 等。 + */ +export function deriveForms(message: AiChatMessage): AiFormSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'form') + .map((artifact) => artifact.payload) + .filter((payload): payload is AiFormSchema => Boolean(payload) && typeof payload === 'object'); +} + +export function deriveReviews(message: AiChatMessage): AiReviewSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'review') + .map((artifact) => artifact.payload) + .filter( + (payload): payload is AiReviewSchema => Boolean(payload) && typeof payload === 'object', + ); +} + +export function deriveCharts(message: AiChatMessage): AiChartSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'chart') + .map((artifact) => artifact.payload) + .filter((payload): payload is AiChartSchema => Boolean(payload) && typeof payload === 'object'); +} diff --git a/scripts/a2ui-contract.md b/scripts/a2ui-contract.md index ef3b335..ad0beae 100644 --- a/scripts/a2ui-contract.md +++ b/scripts/a2ui-contract.md @@ -148,14 +148,15 @@ interface AiUiForm { - **统一类型**:`types.ts` 以 `AiArtifactSchema`(`message.uiArtifacts`)为唯一事实源, `artifact.type` ∈ `form | review | chart | import_wizard`,`payload` 携带各类型 schema。 -- **合并逻辑**:`uiArtifacts.ts` 的 `mergeArtifactIntoMessage` 将新 artifact upsert 进 - `uiArtifacts`,并**向后兼容**地派发到 legacy 字段(`forms`/`reviews`/`charts`)供老消息渲染。 -- **渲染层**:`AiMessageContent.tsx` 消费 legacy 列表渲染 `DynamicForm`/`DynamicReview`/ - `DynamicChart`;每个制品用 `ArtifactErrorBoundary` 包裹(单个渲染失败不影响整条气泡)。 +- **合并逻辑**:`uiArtifacts.ts` 的 `mergeArtifactIntoMessage` 只维护 `uiArtifacts` + (不再派发 legacy);渲染层经 `deriveForms/deriveReviews/deriveCharts` 从 artifact 派生。 +- **SSE 事件**:后端过渡期仍双发 `ui.form/ui.review/ui.chart` 与 `ui.artifact`, + 前端**只消费 `ui.artifact`**,旧事件分支已删除(后端后续可移除旧发射)。 +- **历史兼容**:老消息 metadata 中只有 `a2uiForm/a2uiReview/a2uiChart`(无 uiArtifacts)时, + `sseReducer`/`message-mappers` 恢复为 legacy 字段(`message.forms/reviews/charts`, + 已在 `types.ts` 标注 deprecated),渲染层在 uiArtifacts 为空时回退使用; + 新数据一律只写 `uiArtifacts`。 - **A2UI 组件实现**:`DynamicForm`/`DynamicReview`/`DynamicChart` 共享 `useSubmissionState`(提交状态:防重复提交 + 失败可重试)与 `useXCardSurface` (XCard commands 增量更新 + createSurface 自动去重)。 -- **SSE 事件 / 提交与确认接口**:与上文契约一致,未变更。 - -后续若彻底移除 legacy 字段,需保证历史消息(持久化 metadata 中的 `forms`/`reviews`/`charts`) -仍可渲染——建议先迁移历史数据或保留兼容读取。 +- **提交与确认接口**:与上文契约一致,未变更。