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