- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件 - 删除 imports.preflight 解析器与 PreflightReport 类型 - SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard - 前端同步移除预检类型/组件/测试,保留导入向导
384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import {
|
|
CheckCircleOutlined,
|
|
CloseCircleOutlined,
|
|
LoadingOutlined,
|
|
TableOutlined,
|
|
} from '@ant-design/icons';
|
|
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, { 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,
|
|
AiImportWizard,
|
|
AiReviewSection,
|
|
AiReviewSchema,
|
|
AiReviewSectionType,
|
|
AiToolRun,
|
|
} from './types';
|
|
|
|
const toolLabels: Record<string, string> = {
|
|
search_students: '查询学生',
|
|
get_student_basic: '读取学生信息',
|
|
search_classes: '查询班级',
|
|
get_attendance_summary: '统计考勤',
|
|
search_rooms: '查询房间',
|
|
get_room_occupancy_summary: '统计入住',
|
|
search_bills: '查询账单',
|
|
get_dashboard_stats: '读取经营概览',
|
|
render_form: '生成表单',
|
|
render_review: '生成导入预览',
|
|
render_chart: '生成图表',
|
|
start_import_wizard: '生成导入向导',
|
|
create_student: '创建学生',
|
|
search_exams: '查询考试',
|
|
search_schedules: '查询课表',
|
|
search_deposits: '查询押金',
|
|
search_expenses: '查询费用',
|
|
search_classrooms: '查询教室',
|
|
search_classroom_rentals: '查询教室租用',
|
|
get_sync_status: '查询同步状态',
|
|
get_business_context: '读取业务流程',
|
|
get_entity_schema: '读取实体字典',
|
|
get_pending_tasks: '查询业务待办',
|
|
};
|
|
|
|
const markdownComponents = {
|
|
code: ({ children, lang, block }: ComponentProps) => {
|
|
const content = String(children ?? '').replace(/\n$/, '');
|
|
if (!block) return <code>{content}</code>;
|
|
if (lang === 'mermaid') return <LiteMermaid>{content}</LiteMermaid>;
|
|
return <LiteCodeHighlighter lang={lang}>{content}</LiteCodeHighlighter>;
|
|
},
|
|
};
|
|
|
|
const markdownSanitizerConfig = {
|
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
|
FORBID_ATTR: ['style'],
|
|
};
|
|
|
|
function attachmentIcon(attachment: AiAttachment) {
|
|
if (attachment.mimeType === 'application/pdf') return 'pdf' as const;
|
|
if (attachment.mimeType.includes('wordprocessingml')) return 'word' as const;
|
|
if (attachment.mimeType.includes('spreadsheetml')) return 'excel' as const;
|
|
if (attachment.mimeType.startsWith('image/')) return 'image' as const;
|
|
return 'default' as const;
|
|
}
|
|
|
|
async function openAttachment(attachment: AiAttachment): Promise<void> {
|
|
const token = useUserStore.getState().token;
|
|
const response = await fetch(attachment.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);
|
|
}
|
|
|
|
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[]>(
|
|
() =>
|
|
tools.map((tool) => {
|
|
const running = tool.status === 'running';
|
|
const success = tool.status === 'success';
|
|
return {
|
|
key: tool.toolCallId,
|
|
title: toolLabels[tool.toolName] || tool.toolName,
|
|
description: tool.durationMs ? `${tool.durationMs}ms` : undefined,
|
|
content:
|
|
tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'),
|
|
status: running ? 'loading' : success ? 'success' : 'error',
|
|
icon: running ? (
|
|
<LoadingOutlined spin />
|
|
) : success ? (
|
|
<CheckCircleOutlined />
|
|
) : (
|
|
<CloseCircleOutlined />
|
|
),
|
|
collapsible: Boolean(tool.summary),
|
|
};
|
|
}),
|
|
[tools],
|
|
);
|
|
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;
|
|
editing?: boolean;
|
|
onEditConfirm?: (value: string) => void;
|
|
onEditCancel?: () => 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;
|
|
onOpenImportWizard?: (runId: string) => void;
|
|
}
|
|
|
|
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
|
message,
|
|
status,
|
|
editing,
|
|
onEditConfirm,
|
|
onEditCancel,
|
|
onSubmitForm,
|
|
onSubmitReview,
|
|
onConfirmReviewStep,
|
|
onConfirmReviewGroup,
|
|
onOpenImportWizard,
|
|
}) => {
|
|
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}
|
|
name={attachment.name}
|
|
byte={attachment.size}
|
|
size="small"
|
|
icon={attachmentIcon(attachment)}
|
|
onClick={() => void openAttachment(attachment)}
|
|
/>
|
|
));
|
|
|
|
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 orientation="vertical" size={8} className="ai-chat-user-content">
|
|
<Alert type="success" showIcon title={`已确认导入《${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 orientation="vertical" size={8} className="ai-chat-user-content">
|
|
<Alert type="info" showIcon title={`已提交《${formTitle}》`} />
|
|
</Space>
|
|
);
|
|
}
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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
|
|
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}
|
|
>
|
|
<XMarkdown
|
|
content={message.reasoningContent}
|
|
components={markdownComponents}
|
|
escapeRawHtml
|
|
openLinksInNewTab
|
|
dompurifyConfig={markdownSanitizerConfig}
|
|
streaming={{ hasNextChunk: streaming, tail: streaming }}
|
|
/>
|
|
</Think>
|
|
)}
|
|
{message.toolRuns.length > 0 && <ToolChain tools={message.toolRuns} />}
|
|
{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}
|
|
components={markdownComponents}
|
|
escapeRawHtml
|
|
openLinksInNewTab
|
|
dompurifyConfig={markdownSanitizerConfig}
|
|
streaming={{
|
|
hasNextChunk: streaming,
|
|
enableAnimation: true,
|
|
tail: streaming,
|
|
incompleteMarkdownComponentMap: { link: 'span', image: 'span', table: 'div' },
|
|
}}
|
|
/>
|
|
)}
|
|
{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 title={message.error} />}
|
|
{message.cancelled && <Typography.Text type="secondary">回答已停止</Typography.Text>}
|
|
</Space>
|
|
);
|
|
};
|