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:
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.artifact,legacy 列表由渲染层从 uiArtifacts 派生。
|
||||
mergeArtifactIntoMessage(message, payload.artifact);
|
||||
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
仍可渲染——建议先迁移历史数据或保留兼容读取。
|
||||
- **提交与确认接口**:与上文契约一致,未变更。
|
||||
|
||||
Reference in New Issue
Block a user