113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
import React from 'react';
|
|
import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons';
|
|
import { CodeHighlighter, Think } from '@ant-design/x';
|
|
import XMarkdown from '@ant-design/x-markdown';
|
|
import type { ComponentProps } from '@ant-design/x-markdown';
|
|
import { Alert, Space, Tag, Typography } from 'antd';
|
|
import type { AiChatMessage, AiChatMessageStatus, 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: '读取经营概览',
|
|
};
|
|
|
|
const markdownComponents = {
|
|
code: ({ children, lang, block }: ComponentProps) => {
|
|
const content = String(children ?? '').replace(/\n$/, '');
|
|
if (!block) return <code>{content}</code>;
|
|
return <CodeHighlighter lang={lang || 'text'}>{content}</CodeHighlighter>;
|
|
},
|
|
};
|
|
|
|
const markdownSanitizerConfig = {
|
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
|
FORBID_ATTR: ['style'],
|
|
};
|
|
|
|
function ToolStatus({ tool }: { tool: AiToolRun }) {
|
|
const isRunning = tool.status === 'running';
|
|
const isSuccess = tool.status === 'success';
|
|
const icon = isRunning ? (
|
|
<LoadingOutlined spin />
|
|
) : isSuccess ? (
|
|
<CheckCircleOutlined />
|
|
) : (
|
|
<CloseCircleOutlined />
|
|
);
|
|
const color = isRunning ? 'processing' : isSuccess ? 'success' : 'error';
|
|
const statusText = isRunning ? '查询中' : isSuccess ? '查询完成' : tool.summary || '查询失败';
|
|
return (
|
|
<div className="ai-chat-tool" data-status={tool.status}>
|
|
<Tag icon={icon} color={color}>
|
|
{toolLabels[tool.toolName] || tool.toolName}
|
|
</Tag>
|
|
<Typography.Text type="secondary" className="ai-chat-tool__summary">
|
|
{statusText}
|
|
</Typography.Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const AiMessageContent: React.FC<{
|
|
message: AiChatMessage;
|
|
status?: AiChatMessageStatus;
|
|
}> = ({ message, status }) => {
|
|
if (message.role === 'user') return <div className="ai-chat-user-text">{message.content}</div>;
|
|
const streaming = status === 'loading' || status === 'updating';
|
|
return (
|
|
<Space direction="vertical" size={10} className="ai-chat-answer">
|
|
{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 && (
|
|
<div className="ai-chat-tools" aria-label="工具调用状态">
|
|
{message.toolRuns.map((tool) => (
|
|
<ToolStatus key={tool.toolCallId} tool={tool} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{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',
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
{message.error && <Alert type="error" showIcon message={message.error} />}
|
|
{message.cancelled && <Typography.Text type="secondary">回答已停止</Typography.Text>}
|
|
</Space>
|
|
);
|
|
};
|