feat(ai): 业务上下文感知与 A2UI 链路统一

- 新增代码内业务上下文元数据层(实体字典 + 三大闭环工作流)
- 新增 get_business_context / get_entity_schema / get_pending_tasks 运行时工具
- SYSTEM_PROMPT 与技能目录改为先查业务流程/待办再执行
- A2UI 增加 ai_a2ui_submissions 幂等表、表单过期、ui.artifact 事件
- 提交回灌携带 submissionId / fieldErrors / 下一步建议
- 前端 uiArtifacts 归一化与过期表单禁用
This commit is contained in:
2026-08-06 15:23:23 +08:00
parent d9c541dacc
commit 24e0ecbdaf
44 changed files with 2125 additions and 41 deletions

View File

@@ -56,6 +56,9 @@ const toolLabels: Record<string, string> = {
search_classrooms: '查询教室',
search_classroom_rentals: '查询教室租用',
get_sync_status: '查询同步状态',
get_business_context: '读取业务流程',
get_entity_schema: '读取实体字典',
get_pending_tasks: '查询业务待办',
};
const markdownComponents = {

View File

@@ -84,6 +84,7 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
);
if (!form) return null;
const finished = Boolean(form.submitted) || form.status === 'submitted';
const expired = form.status === 'expired';
const handleFinish = (values: Record<string, unknown>) => {
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
@@ -97,7 +98,9 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
{form.description}
</Typography.Text>
)}
{finished ? (
{expired ? (
<Alert type="warning" showIcon title="表单已失效" description="此表单已被新的请求替代,请让助手重新生成。" />
) : finished ? (
<Alert type="success" showIcon title="已提交AI 正在处理…" />
) : (
<Form

View File

@@ -103,6 +103,38 @@ describe('AI chat bubble rendering', () => {
expect(container.textContent).toContain('已提交');
});
it('renders an expired A2UI form as disabled without submit action', async () => {
let submitted = false;
const el = document.createElement('div');
container = el;
document.body.appendChild(el);
root = createRoot(el);
await act(async () => {
root?.render(
<DynamicForm
form={{
id: 'form-expired',
title: '已失效的表单',
status: 'expired',
fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
}}
onSubmit={() => {
submitted = true;
}}
/>,
);
});
expect(el.textContent).toContain('已失效');
expect(el.querySelector('button[type="submit"]')).toBeNull();
await act(async () => {
const buttons = Array.from(el.querySelectorAll('button'));
buttons.forEach((button) => button.click());
});
expect(submitted).toBe(false);
});
it('renders an A2UI review card and submits via the confirm button', async () => {
let submittedId: string | null = null;
const review: AiReviewSchema = {

View File

@@ -78,6 +78,52 @@ 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', () => {
const mapped = mapHistoryMessage({
id: 8,
role: 'assistant',
content: '请处理',
reasoningContent: null,
status: 'completed',
errorCode: null,
createdAt: '2026-07-23T00:00:00.000Z',
metadata: {
uiArtifacts: [
{
id: 'form-10',
type: 'form',
status: 'submitted',
messageId: 8,
conversationId: 3,
payload: {
id: 'form-10',
title: '新增学生',
status: 'submitted',
fields: [],
},
},
{
id: 'review-10',
type: 'review',
status: 'expired',
messageId: 8,
conversationId: 3,
payload: {
id: 'review-10',
title: '旧预览',
status: 'expired',
sections: [],
},
},
],
},
});
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' });
});
it('restores a persisted A2UI review from message metadata', () => {
const mapped = mapHistoryMessage({
id: 6,

View File

@@ -3,11 +3,13 @@ import type {
AiChatMessage,
AiChatMessageStatus,
AiChartSchema,
AiArtifactSchema,
AiFormSchema,
AiMessageRecord,
AiReviewSchema,
AiToolRun,
} from './types';
import { mergeArtifactIntoMessage } from './provider';
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
if (record.status === 'pending') return 'loading';
@@ -50,24 +52,40 @@ function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {
return [a2uiChart as AiChartSchema];
}
function historyArtifacts(record: AiMessageRecord) {
const artifacts = record.metadata?.uiArtifacts;
if (!Array.isArray(artifacts)) return undefined;
return artifacts.filter(
(item): item is AiArtifactSchema =>
Boolean(item) && typeof item === 'object' && typeof (item as AiArtifactSchema).id === 'string',
);
}
export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> {
const artifacts = historyArtifacts(record);
const baseMessage = {
id: record.id,
role: record.role,
content: record.content || '',
reasoningContent: record.reasoningContent || '',
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
attachments: record.attachments ?? [],
forms: historyForms(record),
reviews: historyReviews(record),
charts: historyCharts(record),
replyToMessageId: record.replyToMessageId,
metadata: record.metadata,
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
cancelled: record.status === 'cancelled',
};
if (artifacts) {
for (const artifact of artifacts) {
mergeArtifactIntoMessage(baseMessage as AiChatMessage, artifact);
}
}
return {
id: record.id,
status: mapStatus(record),
message: {
id: record.id,
role: record.role,
content: record.content || '',
reasoningContent: record.reasoningContent || '',
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
attachments: record.attachments ?? [],
forms: historyForms(record),
reviews: historyReviews(record),
charts: historyCharts(record),
replyToMessageId: record.replyToMessageId,
metadata: record.metadata,
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
cancelled: record.status === 'cancelled',
},
message: baseMessage as AiChatMessage,
};
}

View File

@@ -154,6 +154,41 @@ describe('AI chat SSE message reducer', () => {
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
});
it('merges ui.artifact events into uiArtifacts and legacy lists by id', () => {
let message = reduceAiSseMessage(undefined, {
event: 'ui.artifact',
data: JSON.stringify({
messageId: 12,
artifact: {
id: 'form-1',
type: 'form',
status: 'pending',
messageId: 12,
conversationId: 3,
payload: { id: 'form-1', title: '新增学生', fields: [] },
},
}),
});
message = reduceAiSseMessage(message, {
event: 'ui.artifact',
data: JSON.stringify({
messageId: 12,
artifact: {
id: 'review-1',
type: 'review',
status: 'expired',
messageId: 12,
conversationId: 3,
payload: { id: 'review-1', title: '旧导入预览', status: 'expired', sections: [] },
},
}),
});
expect(message.uiArtifacts).toHaveLength(2);
expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' });
expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' });
});
it('stores ui.import_preflight in message metadata and restores it from completed message', () => {
const preflight = {
verdict: 'needs_input',

View File

@@ -8,6 +8,7 @@ import { usePermissionStore } from '../../store/permission/permissionStore';
import { useUserStore } from '../../store/user/userStore';
import type {
AiAttachment,
AiArtifactSchema,
AiChatInput,
AiChatMessage,
AiChartSchema,
@@ -34,6 +35,7 @@ interface AiSsePayload {
durationMs?: number | null;
attachment?: AiAttachment;
form?: AiFormSchema;
artifact?: AiArtifactSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
preflight?: AiImportPreflight;
@@ -62,6 +64,7 @@ function emptyAssistant(): AiChatMessage {
toolRuns: [],
attachments: [],
forms: [],
uiArtifacts: [],
};
}
@@ -165,10 +168,50 @@ function applyMessagePayload(
message.charts,
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
const artifacts = nested.metadata?.uiArtifacts;
if (Array.isArray(artifacts)) {
for (const artifact of artifacts) {
if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') {
mergeArtifactIntoMessage(message, artifact as AiArtifactSchema);
}
}
}
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
message.metadata = nested.metadata ?? message.metadata;
}
/**
* 将统一 artifact 归入 uiArtifacts并按类型派发到 legacy 列表。
*/
export function mergeArtifactIntoMessage(
message: AiChatMessage,
artifact: AiArtifactSchema,
): AiChatMessage {
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
const payload =
artifact.payload && typeof artifact.payload === 'object'
? (artifact.payload as Record<string, unknown>)
: {};
if (artifact.type === 'form') {
message.forms = mergeForms(message.forms, payload as unknown as AiFormSchema);
} else if (artifact.type === 'review') {
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
payload as unknown as AiReviewSchema,
);
} else if (artifact.type === 'chart') {
message.charts = mergeById<AiChartSchema>(
message.charts,
payload as unknown as AiChartSchema,
);
} else if (artifact.type === 'import_preflight') {
message.metadata = { ...message.metadata, a2uiImportPreflight: payload };
} else if (artifact.type === 'import_wizard') {
message.metadata = { ...message.metadata, a2uiImportWizard: payload };
}
return message;
}
export function reduceAiSseMessage(
originMessage: AiChatMessage | undefined,
chunk?: AiSseChunk,
@@ -198,6 +241,8 @@ export function reduceAiSseMessage(
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) {
mergeArtifactIntoMessage(message, payload.artifact);
} else if (event === 'ui.import_preflight' && payload.preflight) {
message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight };
} else if (event === 'ui.import_wizard' && payload.wizard) {
@@ -319,6 +364,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
> {
/** Routes events that target another (already streamed) message. */
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => void;
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
super({
@@ -404,6 +450,30 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
const { event, payload } = parseSsePayload(info.chunk);
if (
event === 'ui.artifact' &&
payload.artifact &&
typeof payload.messageId === 'number' &&
info.originMessage?.id !== payload.messageId
) {
this.onExternalArtifact?.(payload.messageId, payload.artifact);
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 &&

View File

@@ -52,7 +52,7 @@ export interface AiFormSchema {
description?: string | null;
submitLabel?: string;
fields: AiFormField[];
status?: 'pending' | 'submitted';
status?: 'pending' | 'submitted' | 'expired';
}
export interface AiReviewColumn {
@@ -98,6 +98,27 @@ export interface AiChartSchema {
rows: AiReviewRow[];
}
export type AiArtifactType =
| 'form'
| 'review'
| 'chart'
| 'import_preflight'
| 'import_wizard';
export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
export interface AiArtifactSchema<T = unknown> {
id: string;
type: AiArtifactType;
status: AiArtifactStatus;
messageId: number;
conversationId?: number;
payload: T;
createdAt?: string | null;
submittedAt?: string | null;
supersededBy?: string | null;
}
export interface AiImportWizard {
runId: string;
fileName: string;
@@ -216,6 +237,7 @@ export interface AiChatMessage {
forms?: AiFormSchema[];
reviews?: AiReviewSchema[];
charts?: AiChartSchema[];
uiArtifacts?: AiArtifactSchema[];
replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null;
retrying?: AiModelRetryInfo | null;

View File

@@ -9,7 +9,7 @@ import { useSettingsStore } from '../../store/settings/settingsStore';
import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api';
import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider } from './provider';
import { GongxueAiChatProvider, mergeArtifactIntoMessage } from './provider';
import {
emptyAssistant,
MessageHoverActions,
@@ -111,6 +111,11 @@ export function useAiChatMessageActions({
},
}));
};
provider.onExternalArtifact = (messageId, artifact) => {
setMessage(messageId, (info) => ({
message: mergeArtifactIntoMessage(info.message, artifact),
}));
};
}, [provider, setMessage]);
requestingRef.current = isRequesting;