refactor(admin): AI 对话收敛为统一 ui.artifact 协议,移除 legacy 事件处理
This commit is contained in:
@@ -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: '创建学生',
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.artifact,legacy 列表由渲染层从 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user