feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSsePayload, reduceAiSseMessage } from './provider';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
authenticatedFetch,
|
||||
GongxueAiChatProvider,
|
||||
parseSsePayload,
|
||||
reduceAiSseMessage,
|
||||
} from './provider';
|
||||
|
||||
describe('AI chat SSE message reducer', () => {
|
||||
it('separates reasoning and answer deltas', () => {
|
||||
@@ -120,6 +125,362 @@ 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' }] },
|
||||
],
|
||||
};
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'ui.form',
|
||||
data: JSON.stringify({ messageId: 8, form }),
|
||||
});
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'ui.form',
|
||||
data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }),
|
||||
});
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'ui.form',
|
||||
data: JSON.stringify({
|
||||
messageId: 8,
|
||||
form: { id: 'form-2', title: '入住确认', fields: [] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.forms).toHaveLength(2);
|
||||
expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' });
|
||||
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
|
||||
});
|
||||
|
||||
it('restores a persisted form from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 12,
|
||||
content: '请填写表单',
|
||||
status: 'completed',
|
||||
metadata: {
|
||||
a2uiForm: {
|
||||
id: 'form-9',
|
||||
title: '新增学生',
|
||||
fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.forms).toHaveLength(1);
|
||||
expect(message.forms?.[0].id).toBe('form-9');
|
||||
});
|
||||
|
||||
it('merges ui.review events into the assistant message and updates by id', () => {
|
||||
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}}' },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.reviews).toHaveLength(1);
|
||||
expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' });
|
||||
});
|
||||
|
||||
it('shows model retrying state and clears it when content starts', () => {
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'model.retrying',
|
||||
data: JSON.stringify({
|
||||
messageId: 8,
|
||||
retry: { attempt: 1, maxRetries: 3, delayMs: 500, reason: '上游返回 503' },
|
||||
}),
|
||||
});
|
||||
expect(message.retrying).toMatchObject({ attempt: 1, maxRetries: 3 });
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'content.delta',
|
||||
data: JSON.stringify({ messageId: 8, delta: '你好' }),
|
||||
});
|
||||
expect(message.retrying).toBeNull();
|
||||
expect(message.content).toContain('你好');
|
||||
});
|
||||
|
||||
it('restores a persisted review from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 12,
|
||||
content: '请审阅',
|
||||
status: 'completed',
|
||||
metadata: {
|
||||
a2uiReview: {
|
||||
id: 'review-9',
|
||||
title: '批量导入',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{
|
||||
key: 'rooms',
|
||||
type: 'rooms',
|
||||
title: '宿舍',
|
||||
kind: 'table',
|
||||
columns: [{ key: 'roomNumber', title: '房间号' }],
|
||||
rows: [{ roomNumber: '3-301' }],
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.reviews).toHaveLength(1);
|
||||
expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
|
||||
});
|
||||
|
||||
it('merges ui.chart events into the assistant message by id', () => {
|
||||
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: '女生人数' },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.charts).toHaveLength(2);
|
||||
expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' });
|
||||
expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' });
|
||||
});
|
||||
|
||||
it('restores persisted charts from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 12,
|
||||
content: '这是图表',
|
||||
status: 'completed',
|
||||
metadata: {
|
||||
a2uiChart: [
|
||||
{
|
||||
id: 'chart-9',
|
||||
title: '男女比例',
|
||||
chartType: 'pie',
|
||||
columns: [
|
||||
{ key: 'name', title: '性别' },
|
||||
{ key: 'value', title: '人数' },
|
||||
],
|
||||
rows: [
|
||||
{ name: '男', value: 20 },
|
||||
{ name: '女', value: 15 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.charts).toHaveLength(1);
|
||||
expect(message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'pie' });
|
||||
});
|
||||
|
||||
it('rewrites review submissions to the review submit stream endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: '确认批量导入',
|
||||
attachmentIds: [],
|
||||
skillKey: null,
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
reviewSubmission: { reviewId: 'review-1', reviewTitle: '开学导入' },
|
||||
}),
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'http://x/api/ai/chat/reviews/review-1/submit/stream',
|
||||
);
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reasoningEffort when rewriting regenerate requests', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: '',
|
||||
attachmentIds: [],
|
||||
skillKey: null,
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
regenerateMessageId: 99,
|
||||
}),
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'http://x/api/ai/chat/conversations/3/messages/99/regenerate/stream',
|
||||
);
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reasoningEffort when rewriting form submissions', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: '',
|
||||
attachmentIds: [],
|
||||
skillKey: null,
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
formSubmission: { formId: 'form-1', values: { name: '张三' } },
|
||||
}),
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'http://x/api/ai/chat/forms/form-1/submit/stream',
|
||||
);
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
values: { name: '张三' },
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes submit-time ui.review to the original message instead of the streaming one', () => {
|
||||
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: '批量导入',
|
||||
status: 'submitted',
|
||||
sections: [],
|
||||
};
|
||||
const origin = {
|
||||
id: 13,
|
||||
role: 'assistant' as const,
|
||||
content: '生成中',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
reviews: [],
|
||||
};
|
||||
const next = provider.transformMessage({
|
||||
originMessage: origin,
|
||||
chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) },
|
||||
status: 'updating',
|
||||
chunks: [],
|
||||
responseHeaders: {} as Headers,
|
||||
});
|
||||
|
||||
expect(onExternalReview).toHaveBeenCalledWith(12, review);
|
||||
expect(next).toBe(origin);
|
||||
expect(next.reviews ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('routes ui.review 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 next = provider.transformMessage({
|
||||
chunk: {
|
||||
event: 'ui.review',
|
||||
data: JSON.stringify({
|
||||
messageId: 12,
|
||||
review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] },
|
||||
}),
|
||||
},
|
||||
status: 'updating',
|
||||
chunks: [],
|
||||
responseHeaders: {} as Headers,
|
||||
});
|
||||
|
||||
expect(onExternalReview).toHaveBeenCalledWith(
|
||||
12,
|
||||
expect.objectContaining({ id: 'review-1' }),
|
||||
);
|
||||
expect(next.reviews ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tolerates non-JSON event data', () => {
|
||||
expect(parseSsePayload({ event: 'content.delta', data: 'plain text' })).toEqual({
|
||||
event: 'content.delta',
|
||||
|
||||
Reference in New Issue
Block a user