feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取
This commit is contained in:
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user