feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具

This commit is contained in:
2026-08-05 17:11:00 +08:00
parent 644c35ce53
commit 0e6e3e2d96
64 changed files with 8395 additions and 6434 deletions

View File

@@ -1,39 +1,30 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import {
CheckCircleOutlined,
CloseCircleOutlined,
CopyOutlined,
DislikeFilled,
DislikeOutlined,
LikeFilled,
LikeOutlined,
LoadingOutlined,
ReloadOutlined,
TableOutlined,
} from '@ant-design/icons';
import {
Actions,
CodeHighlighter,
FileCard,
Mermaid,
Sources,
Think,
ThoughtChain,
} from '@ant-design/x';
import FileCard from '@ant-design/x/es/file-card';
import Sources from '@ant-design/x/es/sources';
import Think from '@ant-design/x/es/think';
import ThoughtChain from '@ant-design/x/es/thought-chain';
import type { ThoughtChainItemType } from '@ant-design/x';
import XMarkdown from '@ant-design/x-markdown';
import type { ComponentProps } from '@ant-design/x-markdown';
import { Alert, Flex, Space, Typography } from 'antd';
import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown';
import { Alert, Button, Flex, Input, Space, Typography } from 'antd';
import { useUserStore } from '../../store/user/userStore';
import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview';
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
import { LiteMermaid } from './LiteMermaid';
import type {
AiAttachment,
AiChatMessage,
AiChatMessageStatus,
AiChartSchema,
AiFormSchema,
AiMessageFeedback,
AiImportWizard,
AiReviewSection,
AiReviewSchema,
AiReviewSectionType,
@@ -52,6 +43,7 @@ const toolLabels: Record<string, string> = {
render_form: '生成表单',
render_review: '生成导入预览',
render_chart: '生成图表',
start_import_wizard: '生成导入向导',
create_student: '创建学生',
search_exams: '查询考试',
search_schedules: '查询课表',
@@ -66,8 +58,8 @@ const markdownComponents = {
code: ({ children, lang, block }: ComponentProps) => {
const content = String(children ?? '').replace(/\n$/, '');
if (!block) return <code>{content}</code>;
if (lang === 'mermaid') return <Mermaid>{content}</Mermaid>;
return <CodeHighlighter lang={lang || 'text'}>{content}</CodeHighlighter>;
if (lang === 'mermaid') return <LiteMermaid>{content}</LiteMermaid>;
return <LiteCodeHighlighter lang={lang}>{content}</LiteCodeHighlighter>;
},
};
@@ -118,7 +110,8 @@ function ToolChain({ tools }: { tools: AiToolRun[] }) {
key: tool.toolCallId,
title: toolLabels[tool.toolName] || tool.toolName,
description: tool.durationMs ? `${tool.durationMs}ms` : undefined,
content: tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'),
content:
tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'),
status: running ? 'loading' : success ? 'success' : 'error',
icon: running ? (
<LoadingOutlined spin />
@@ -135,11 +128,51 @@ function ToolChain({ tools }: { tools: AiToolRun[] }) {
return <ThoughtChain items={items} line="solid" />;
}
function EditUserContent({
initial,
onConfirm,
onCancel,
}: {
initial: string;
onConfirm: (value: string) => void;
onCancel?: () => void;
}) {
const [draft, setDraft] = useState(initial);
return (
<Space orientation="vertical" size={8} className="ai-chat-user-edit">
<Input.TextArea
value={draft}
onChange={(event) => setDraft(event.target.value)}
autoSize={{ minRows: 2, maxRows: 8 }}
onKeyDown={(event) => {
// 中文输入法合成中的回车不应触发保存
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onConfirm(draft);
} else if (event.key === 'Escape') {
onCancel?.();
}
}}
/>
<Flex gap={8} justify="flex-end">
<Button size="small" onClick={onCancel}>
</Button>
<Button size="small" type="primary" onClick={() => onConfirm(draft)}>
</Button>
</Flex>
</Space>
);
}
export interface AiMessageContentProps {
message: AiChatMessage;
status?: AiChatMessageStatus;
onReload?: () => void;
onFeedback?: (feedback: AiMessageFeedback) => void;
editing?: boolean;
onEditConfirm?: (value: string) => void;
onEditCancel?: () => void;
onSubmitForm?: (form: AiFormSchema, values: Record<string, unknown>) => void;
onSubmitReview?: (reviewId: string, reviewTitle?: string) => void;
onConfirmReviewStep?: (
@@ -152,17 +185,20 @@ export interface AiMessageContentProps {
reviewId: string,
type: AiReviewSectionType,
) => AiReviewSchema | Promise<AiReviewSchema> | void;
onOpenImportWizard?: (runId: string) => void;
}
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
message,
status,
onReload,
onFeedback,
editing,
onEditConfirm,
onEditCancel,
onSubmitForm,
onSubmitReview,
onConfirmReviewStep,
onConfirmReviewGroup,
onOpenImportWizard,
}) => {
const streaming = status === 'loading' || status === 'updating';
const formSubmission = message.metadata?.a2uiSubmit;
@@ -199,8 +235,8 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
? String((reviewSubmission as Record<string, unknown>).reviewTitle)
: '批量导入';
return (
<Space direction="vertical" size={8} className="ai-chat-user-content">
<Alert type="success" showIcon message={`已确认导入《${reviewTitle}`} />
<Space orientation="vertical" size={8} className="ai-chat-user-content">
<Alert type="success" showIcon title={`已确认导入《${reviewTitle}`} />
</Space>
);
}
@@ -210,64 +246,55 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
? String((formSubmission as Record<string, unknown>).formTitle)
: '表单';
return (
<Space direction="vertical" size={8} className="ai-chat-user-content">
<Alert type="info" showIcon message={`已提交《${formTitle}`} />
<Space orientation="vertical" size={8} className="ai-chat-user-content">
<Alert type="info" showIcon title={`已提交《${formTitle}`} />
</Space>
);
}
return (
<Space direction="vertical" size={8} className="ai-chat-user-content">
{attachmentCards.length > 0 && <Flex wrap gap={8}>{attachmentCards}</Flex>}
<div className="ai-chat-user-text">{message.content}</div>
<Space orientation="vertical" size={8} className="ai-chat-user-content">
{attachmentCards.length > 0 && (
<Flex wrap gap={8}>
{attachmentCards}
</Flex>
)}
{editing ? (
<EditUserContent
initial={message.content}
onConfirm={(value) => onEditConfirm?.(value)}
onCancel={onEditCancel}
/>
) : (
<div className="ai-chat-user-text">{message.content}</div>
)}
</Space>
);
}
const actionItems = [
{
key: 'copy',
label: '复制',
icon: <CopyOutlined />,
onItemClick: () => void navigator.clipboard.writeText(message.content),
},
...(onReload
? [{ key: 'reload', label: '重新生成', icon: <ReloadOutlined />, onItemClick: onReload }]
: []),
...(onFeedback
? [
{
key: 'like',
label: '有帮助',
icon: message.feedback === 'like' ? <LikeFilled /> : <LikeOutlined />,
onItemClick: () => onFeedback(message.feedback === 'like' ? null : 'like'),
},
{
key: 'dislike',
label: '没帮助',
icon: message.feedback === 'dislike' ? <DislikeFilled /> : <DislikeOutlined />,
onItemClick: () => onFeedback(message.feedback === 'dislike' ? null : 'dislike'),
},
]
: []),
];
return (
<Space direction="vertical" size={10} className="ai-chat-answer">
{streaming && !message.content && !message.reasoningContent && message.toolRuns.length === 0 && (
<div className="ai-chat-streaming-placeholder" role="status" aria-label="生成中">
<LoadingOutlined spin />
</div>
)}
<Space orientation="vertical" size={10} className="ai-chat-answer">
{streaming &&
!message.content &&
!message.reasoningContent &&
message.toolRuns.length === 0 && (
<div className="ai-chat-streaming-placeholder" role="status" aria-label="生成中">
<LoadingOutlined spin />
</div>
)}
{message.retrying && (
<Alert
type="warning"
showIcon
message={`AI 服务繁忙,正在自动重试(第 ${message.retrying.attempt} / ${message.retrying.maxRetries} 次)...`}
title={`AI 服务繁忙,正在自动重试(第 ${message.retrying.attempt} / ${message.retrying.maxRetries} 次)...`}
description={message.retrying.reason ? `原因:${message.retrying.reason}` : undefined}
/>
)}
{message.reasoningContent && (
<Think title={streaming ? '正在思考' : '思考过程'} loading={streaming} defaultExpanded={false}>
<Think
title={streaming ? '正在思考' : '思考过程'}
loading={streaming}
defaultExpanded={false}
>
<XMarkdown
content={message.reasoningContent}
components={markdownComponents}
@@ -279,7 +306,29 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
</Think>
)}
{message.toolRuns.length > 0 && <ToolChain tools={message.toolRuns} />}
{attachmentCards.length > 0 && <Flex wrap gap={8}>{attachmentCards}</Flex>}
{attachmentCards.length > 0 && (
<Flex wrap gap={8}>
{attachmentCards}
</Flex>
)}
{(() => {
const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined;
if (!wizard || !onOpenImportWizard) return null;
return (
<Flex wrap gap={8} align="center">
<Button
type="primary"
icon={<TableOutlined />}
onClick={() => onOpenImportWizard(wizard.runId)}
>
</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{wizard.fileName}
</Typography.Text>
</Flex>
);
})()}
{message.content && (
<XMarkdown
content={message.content}
@@ -324,9 +373,8 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
{(message.charts ?? []).map((chart: AiChartSchema) => (
<DynamicChart key={chart.id} chart={chart} />
))}
{message.error && <Alert type="error" showIcon message={message.error} />}
{message.error && <Alert type="error" showIcon title={message.error} />}
{message.cancelled && <Typography.Text type="secondary"></Typography.Text>}
{!streaming && message.content && <Actions items={actionItems} fadeIn />}
</Space>
);
};