feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取

This commit is contained in:
2026-08-04 14:41:40 +08:00
parent f07ffdc64c
commit 50c44e4410
51 changed files with 11588 additions and 175 deletions

View File

@@ -0,0 +1,239 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { XCard, registerCatalog } from '@ant-design/x-card';
import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card';
import { Alert, Button, DatePicker, Flex, Form, Input, InputNumber, Select, Typography } from 'antd';
import dayjs from 'dayjs';
import type { AiFormField, AiFormSchema } from './types';
const FORM_CATALOG_ID = 'gongxue-form-catalog';
registerCatalog({
catalogId: FORM_CATALOG_ID,
components: {
FormPreview: {
type: 'object',
properties: {
form: { type: 'object' },
disabled: { type: 'boolean' },
},
},
},
});
function surfaceId(formId: string): string {
return `form-${formId}`;
}
function initialValue(field: AiFormField): unknown {
if (field.type === 'date' && typeof field.defaultValue === 'string') {
const parsed = dayjs(field.defaultValue);
return parsed.isValid() ? parsed : undefined;
}
return field.defaultValue;
}
function normalizeValues(
fields: AiFormField[],
raw: Record<string, unknown>,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const field of fields) {
const value = raw[field.name];
if (value === undefined || value === null || value === '') continue;
values[field.name] =
field.type === 'date' && dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : value;
}
return values;
}
interface FormPreviewProps {
form?: AiFormSchema;
disabled?: boolean;
onAction?: (name: string, context: Record<string, unknown>) => void;
}
/**
* A2UI component registered for the `gongxue-form-catalog` catalog.
* Receives the validated form schema through data binding and reports
* normalized values back through the `form:submit` action.
*/
const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) => {
const runtime = form as unknown as {
submitting?: boolean;
submitted?: boolean;
error?: string | null;
};
const submitting = Boolean(runtime.submitting);
const initialValues = useMemo(
() => Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])),
[form?.fields],
);
if (!form) return null;
const finished = Boolean(runtime.submitted) || form.status === 'submitted';
const handleFinish = (values: Record<string, unknown>) => {
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
};
return (
<Flex vertical gap={8}>
<Typography.Text strong>{form.title}</Typography.Text>
{form.description && (
<Typography.Text type="secondary" className="ai-chat-dynamic-form__desc">
{form.description}
</Typography.Text>
)}
{finished ? (
<Alert type="success" showIcon message="已提交AI 正在处理…" />
) : (
<Form
layout="vertical"
size="small"
initialValues={initialValues}
onFinish={(values) => void handleFinish(values as Record<string, unknown>)}
disabled={disabled || submitting}
requiredMark={false}
>
{form.fields.map((field) => (
<Form.Item
key={field.name}
name={field.name}
label={field.label}
rules={[
{
required: field.required,
message: field.required
? field.type === 'select' || field.type === 'date'
? `请选择${field.label}`
: `请输入${field.label}`
: undefined,
},
]}
>
{field.type === 'textarea' ? (
<Input.TextArea rows={3} placeholder={field.placeholder} />
) : field.type === 'number' ? (
<InputNumber
className="ai-chat-dynamic-form__number"
placeholder={field.placeholder}
/>
) : field.type === 'select' ? (
<Select
allowClear={!field.required}
placeholder={field.placeholder}
options={field.options}
/>
) : field.type === 'date' ? (
<DatePicker className="ai-chat-dynamic-form__date" placeholder={field.placeholder} />
) : (
<Input placeholder={field.placeholder} />
)}
</Form.Item>
))}
{runtime.error && (
<Alert
type="error"
showIcon
message={runtime.error}
className="ai-chat-dynamic-form__error"
/>
)}
<Button type="primary" htmlType="submit" loading={submitting} disabled={disabled}>
{form.submitLabel || '提交'}
</Button>
</Form>
)}
</Flex>
);
};
export interface DynamicFormProps {
form: AiFormSchema;
disabled?: boolean;
onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
}
/**
* A2UI form rendered through the official @ant-design/x-card renderer.
* The validated schema is bound into the surface data model; submit
* success/failure/loading transitions are pushed as incremental commands.
*/
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
useEffect(() => {
const sid = surfaceId(form.id);
if (idRef.current !== sid) {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
});
}
cmds.push({
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/form',
value: { ...form, submitting, submitted, error },
},
});
cmds.push({
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'FormPreview',
form: { path: '/form' },
disabled: Boolean(disabled),
},
],
},
});
setCommands([...cmds]);
}, [disabled, error, form, submitted, submitting]);
const handleSubmit = async (values: Record<string, unknown>) => {
if (submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(values);
setSubmitted(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
};
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'form:submit') return;
const values =
payload.context?.values && typeof payload.context.values === 'object'
? (payload.context.values as Record<string, unknown>)
: {};
void handleSubmit(values);
};
return (
<div className="ai-chat-dynamic-form">
<XCard.Box components={{ FormPreview }} commands={commands} onAction={handleAction}>
<XCard.Card id={surfaceId(form.id)} />
</XCard.Box>
</div>
);
};
export default DynamicForm;