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

@@ -10,16 +10,33 @@ import {
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Actions, CodeHighlighter, FileCard, Think, ThoughtChain } from '@ant-design/x';
import {
Actions,
CodeHighlighter,
FileCard,
Mermaid,
Sources,
Think,
ThoughtChain,
} from '@ant-design/x';
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 { useUserStore } from '../../store/user/userStore';
import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview';
import type {
AiAttachment,
AiChatMessage,
AiChatMessageStatus,
AiChartSchema,
AiFormSchema,
AiMessageFeedback,
AiReviewSection,
AiReviewSchema,
AiReviewSectionType,
AiToolRun,
} from './types';
@@ -32,12 +49,24 @@ const toolLabels: Record<string, string> = {
get_room_occupancy_summary: '统计入住',
search_bills: '查询账单',
get_dashboard_stats: '读取经营概览',
render_form: '生成表单',
render_review: '生成导入预览',
render_chart: '生成图表',
create_student: '创建学生',
search_exams: '查询考试',
search_schedules: '查询课表',
search_deposits: '查询押金',
search_expenses: '查询费用',
search_classrooms: '查询教室',
search_classroom_rentals: '查询教室租用',
get_sync_status: '查询同步状态',
};
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>;
},
};
@@ -57,7 +86,7 @@ function attachmentIcon(attachment: AiAttachment) {
}
async function openAttachment(attachment: AiAttachment): Promise<void> {
const token = localStorage.getItem('token');
const token = useUserStore.getState().token;
const response = await fetch(attachment.url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
@@ -67,6 +96,18 @@ async function openAttachment(attachment: AiAttachment): Promise<void> {
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
async function openSourceUrl(item: { url?: string }): Promise<void> {
if (!item.url) return;
const token = useUserStore.getState().token;
const response = await fetch(item.url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!response.ok) throw new Error('来源打开失败');
const objectUrl = URL.createObjectURL(await response.blob());
window.open(objectUrl, '_blank', 'noopener,noreferrer');
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
function ToolChain({ tools }: { tools: AiToolRun[] }) {
const items = useMemo<ThoughtChainItemType[]>(
() =>
@@ -99,6 +140,18 @@ export interface AiMessageContentProps {
status?: AiChatMessageStatus;
onReload?: () => void;
onFeedback?: (feedback: AiMessageFeedback) => void;
onSubmitForm?: (form: AiFormSchema, values: Record<string, unknown>) => void;
onSubmitReview?: (reviewId: string, reviewTitle?: string) => void;
onConfirmReviewStep?: (
messageId: number | undefined,
reviewId: string,
sectionKey: AiReviewSection['key'],
) => AiReviewSchema | Promise<AiReviewSchema> | void;
onConfirmReviewGroup?: (
messageId: number | undefined,
reviewId: string,
type: AiReviewSectionType,
) => AiReviewSchema | Promise<AiReviewSchema> | void;
}
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
@@ -106,8 +159,28 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
status,
onReload,
onFeedback,
onSubmitForm,
onSubmitReview,
onConfirmReviewStep,
onConfirmReviewGroup,
}) => {
const streaming = status === 'loading' || status === 'updating';
const formSubmission = message.metadata?.a2uiSubmit;
const reviewSubmission = message.metadata?.a2uiReviewSubmit;
const sourceMeta = message.metadata?.a2uiSources;
const sourceItems = Array.isArray(sourceMeta)
? sourceMeta
.filter(
(item): item is { title: string; url?: string; description?: string } =>
Boolean(item) && typeof (item as { title?: unknown }).title === 'string',
)
.map((item, index) => ({
key: `source-${index}`,
title: item.title,
...(item.url ? { url: item.url } : {}),
...(item.description ? { description: item.description } : {}),
}))
: [];
const attachmentCards = message.attachments.map((attachment) => (
<FileCard
key={attachment.id}
@@ -120,6 +193,28 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
));
if (message.role === 'user') {
if (reviewSubmission && typeof reviewSubmission === 'object') {
const reviewTitle =
typeof (reviewSubmission as Record<string, unknown>).reviewTitle === 'string'
? 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>
);
}
if (formSubmission && typeof formSubmission === 'object') {
const formTitle =
typeof (formSubmission as Record<string, unknown>).formTitle === 'string'
? 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>
);
}
return (
<Space direction="vertical" size={8} className="ai-chat-user-content">
{attachmentCards.length > 0 && <Flex wrap gap={8}>{attachmentCards}</Flex>}
@@ -158,6 +253,19 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
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>
)}
{message.retrying && (
<Alert
type="warning"
showIcon
message={`AI 服务繁忙,正在自动重试(第 ${message.retrying.attempt} / ${message.retrying.maxRetries} 次)...`}
description={message.retrying.reason ? `原因:${message.retrying.reason}` : undefined}
/>
)}
{message.reasoningContent && (
<Think title={streaming ? '正在思考' : '思考过程'} loading={streaming} defaultExpanded={false}>
<XMarkdown
@@ -187,6 +295,35 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
}}
/>
)}
{sourceItems.length > 0 && (
<Sources
items={sourceItems}
title="引用来源"
onClick={(item) => void openSourceUrl(item as { url?: string })}
/>
)}
{(message.forms ?? []).map((form) => (
<DynamicForm
key={form.id}
form={form}
disabled={streaming}
onSubmit={(values) => onSubmitForm?.(form, values)}
/>
))}
{(message.reviews ?? []).map((review: AiReviewSchema) => (
<DynamicReview
key={review.id}
review={review}
messageId={typeof message.id === 'number' ? message.id : undefined}
disabled={streaming}
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
onConfirmStep={onConfirmReviewStep}
onConfirmGroup={onConfirmReviewGroup}
/>
))}
{(message.charts ?? []).map((chart: AiChartSchema) => (
<DynamicChart key={chart.id} chart={chart} />
))}
{message.error && <Alert type="error" showIcon message={message.error} />}
{message.cancelled && <Typography.Text type="secondary"></Typography.Text>}
{!streaming && message.content && <Actions items={actionItems} fadeIn />}