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:
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user