refactor(admin): A2UI 双轨收敛,统一 artifact 协议

- 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
This commit is contained in:
2026-08-08 09:33:53 +08:00
parent a644a8de42
commit bcd2d1a559
7 changed files with 98 additions and 79 deletions

View File

@@ -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<AiMessageContentProps> = ({
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<AiMessageContentProps> = ({
onClick={(item) => void handleOpenSource(item as { url?: string })}
/>
)}
{(message.forms ?? []).map((form) => (
{(forms ?? []).map((form) => (
<ArtifactErrorBoundary key={form.id} title="表单">
<DynamicForm
form={form}
@@ -383,7 +389,7 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
/>
</ArtifactErrorBoundary>
))}
{(message.reviews ?? []).map((review: AiReviewSchema) => (
{(reviews ?? []).map((review: AiReviewSchema) => (
<ArtifactErrorBoundary key={review.id} title="导入预览">
<DynamicReview
review={review}
@@ -395,7 +401,7 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
/>
</ArtifactErrorBoundary>
))}
{(message.charts ?? []).map((chart: AiChartSchema) => (
{(charts ?? []).map((chart: AiChartSchema) => (
<ArtifactErrorBoundary key={chart.id} title="图表">
<DynamicChart chart={chart} />
</ArtifactErrorBoundary>

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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<AiFormSchema>(
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<AiReviewSchema>(message.reviews, payload.review);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
} else if (event === 'ui.artifact' && payload.artifact) {
// 统一 artifact 事件:后端过渡期仍双发 ui.form/ui.review/ui.chart
// 前端只消费 ui.artifactlegacy 列表由渲染层从 uiArtifacts 派生。
mergeArtifactIntoMessage(message, payload.artifact);
} else if (event === 'ui.import_wizard' && payload.wizard) {
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };

View File

@@ -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<string, unknown> | null;

View File

@@ -25,43 +25,49 @@ export function mergeById<T extends { id: string }>(
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<AiArtifactSchema>(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<AiReviewSchema>(message.reviews, payload as AiReviewSchema);
} else if (artifact.type === 'chart') {
message.charts = mergeById<AiChartSchema>(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');
}

View File

@@ -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`
仍可渲染——建议先迁移历史数据或保留兼容读取。
- **提交与确认接口**:与上文契约一致,未变更。