Refactor AI chat: streaming, tool calls, UI polish
All checks were successful
CI / check (pull_request) Successful in 3m25s
All checks were successful
CI / check (pull_request) Successful in 3m25s
This commit is contained in:
@@ -1,10 +1,27 @@
|
||||
import React from 'react';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { CodeHighlighter, Think } from '@ant-design/x';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
DislikeFilled,
|
||||
DislikeOutlined,
|
||||
LikeFilled,
|
||||
LikeOutlined,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Actions, CodeHighlighter, FileCard, 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, Space, Tag, Typography } from 'antd';
|
||||
import type { AiChatMessage, AiChatMessageStatus, AiToolRun } from './types';
|
||||
import { Alert, Flex, Space, Typography } from 'antd';
|
||||
import type {
|
||||
AiAttachment,
|
||||
AiChatMessage,
|
||||
AiChatMessageStatus,
|
||||
AiMessageFeedback,
|
||||
AiToolRun,
|
||||
} from './types';
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
search_students: '查询学生',
|
||||
@@ -31,44 +48,118 @@ const markdownSanitizerConfig = {
|
||||
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>
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
export const AiMessageContent: React.FC<{
|
||||
async function openAttachment(attachment: AiAttachment): Promise<void> {
|
||||
const token = localStorage.getItem('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);
|
||||
}
|
||||
|
||||
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" />;
|
||||
}
|
||||
|
||||
export interface AiMessageContentProps {
|
||||
message: AiChatMessage;
|
||||
status?: AiChatMessageStatus;
|
||||
}> = ({ message, status }) => {
|
||||
if (message.role === 'user') return <div className="ai-chat-user-text">{message.content}</div>;
|
||||
onReload?: () => void;
|
||||
onFeedback?: (feedback: AiMessageFeedback) => void;
|
||||
}
|
||||
|
||||
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
message,
|
||||
status,
|
||||
onReload,
|
||||
onFeedback,
|
||||
}) => {
|
||||
const streaming = status === 'loading' || status === 'updating';
|
||||
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') {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{message.reasoningContent && (
|
||||
<Think
|
||||
title={streaming ? '正在思考' : '思考过程'}
|
||||
loading={streaming}
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<Think title={streaming ? '正在思考' : '思考过程'} loading={streaming} defaultExpanded={false}>
|
||||
<XMarkdown
|
||||
content={message.reasoningContent}
|
||||
components={markdownComponents}
|
||||
@@ -79,13 +170,8 @@ export const AiMessageContent: React.FC<{
|
||||
/>
|
||||
</Think>
|
||||
)}
|
||||
{message.toolRuns.length > 0 && (
|
||||
<div className="ai-chat-tools" aria-label="工具调用状态">
|
||||
{message.toolRuns.map((tool) => (
|
||||
<ToolStatus key={tool.toolCallId} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.toolRuns.length > 0 && <ToolChain tools={message.toolRuns} />}
|
||||
{attachmentCards.length > 0 && <Flex wrap gap={8}>{attachmentCards}</Flex>}
|
||||
{message.content && (
|
||||
<XMarkdown
|
||||
content={message.content}
|
||||
@@ -97,16 +183,13 @@ export const AiMessageContent: React.FC<{
|
||||
hasNextChunk: streaming,
|
||||
enableAnimation: true,
|
||||
tail: streaming,
|
||||
incompleteMarkdownComponentMap: {
|
||||
link: 'span',
|
||||
image: 'span',
|
||||
table: 'div',
|
||||
},
|
||||
incompleteMarkdownComponentMap: { link: 'span', image: 'span', table: 'div' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{message.error && <Alert type="error" showIcon message={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