Refactor AI chat: streaming, tool calls, UI polish
All checks were successful
CI / check (pull_request) Successful in 3m25s

This commit is contained in:
2026-07-24 16:27:50 +08:00
parent e605586fc9
commit 8656394b9b
55 changed files with 2774 additions and 460 deletions

View File

@@ -1,24 +1,44 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DeleteOutlined,
EditOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PlusOutlined,
ReloadOutlined,
RobotOutlined,
} from '@ant-design/icons';
import { Bubble, Conversations, Sender } from '@ant-design/x';
import type { BubbleItemType, BubbleListProps, ConversationItemType } from '@ant-design/x';
import { useXChat, type MessageInfo } from '@ant-design/x-sdk';
import { Button, Drawer, Empty, Grid, Input, Modal, Spin, Tooltip, Typography } from 'antd';
import type { MenuProps } from 'antd';
import {
Attachments,
Bubble,
Conversations,
Prompts,
Sender,
Welcome,
} from '@ant-design/x';
import type {
BubbleItemType,
BubbleListProps,
ConversationItemType,
PromptsItemType,
} from '@ant-design/x';
import type { Attachment } from '@ant-design/x/es/attachments';
import { useXChat, useXConversations, type MessageInfo } from '@ant-design/x-sdk';
import { Button, Drawer, Dropdown, Grid, Input, Modal, Spin, Tooltip, Typography } from 'antd';
import type { MenuProps, UploadFile, UploadProps } from 'antd';
import { message } from '../../ui/app-message';
import { aiChatApi, conversationStreamUrl } from './api';
import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider } from './provider';
import type { AiChatInput, AiChatMessage, AiConversation, AiSseChunk } from './types';
import type {
AiAttachment,
AiChatInput,
AiChatMessage,
AiChatMessageStatus,
AiConversation,
AiSkill,
AiSseChunk,
} from './types';
import './style.css';
interface AiChatDrawerProps {
@@ -26,6 +46,11 @@ interface AiChatDrawerProps {
onClose: () => void;
}
interface ConversationData extends AiConversation {
key: string;
label: string;
}
function sortConversations(items: AiConversation[]): AiConversation[] {
return [...items].sort((a, b) => {
const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();
@@ -34,41 +59,77 @@ function sortConversations(items: AiConversation[]): AiConversation[] {
});
}
function toConversationData(item: AiConversation): ConversationData {
return { ...item, key: String(item.id), label: item.title };
}
function toUploadFile(attachment: AiAttachment): Attachment<AiAttachment> {
return {
uid: String(attachment.id),
name: attachment.name,
size: attachment.size,
status: attachment.status === 'ready' ? 'done' : attachment.status === 'failed' ? 'error' : 'uploading',
url: attachment.url,
response: attachment,
description: attachment.error || undefined,
cardType: attachment.mimeType.startsWith('image/') ? 'image' : 'file',
};
}
function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
};
}
export const aiBubbleRoles: BubbleListProps['role'] = {
user: {
placement: 'end',
variant: 'filled',
contentRender: (content: AiChatMessage) => <AiMessageContent message={content} />,
},
assistant: {
placement: 'start',
variant: 'borderless',
contentRender: (content: AiChatMessage, info) => (
<AiMessageContent message={content} status={info.status} />
),
},
user: { placement: 'end', variant: 'filled', shape: 'corner' },
assistant: { placement: 'start', variant: 'borderless' },
};
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const [conversations, setConversations] = useState<AiConversation[]>([]);
const [activeId, setActiveId] = useState<number | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
const [input, setInput] = useState('');
const requestingRef = React.useRef(false);
const [skills, setSkills] = useState<AiSkill[]>([]);
const [attachments, setAttachments] = useState<AiAttachment[]>([]);
const requestingRef = useRef(false);
const abortRef = useRef<() => void>(() => undefined);
const attachmentsRef = useRef<AiAttachment[]>([]);
const {
conversations,
activeConversationKey,
setActiveConversationKey,
addConversation,
removeConversation,
setConversation,
setConversations,
} = useXConversations({});
const activeConversation = useMemo(
() => conversations.find((item) => item.key === activeConversationKey) as ConversationData | undefined,
[activeConversationKey, conversations],
);
const activeId = activeConversation?.id ?? null;
const lockedSkill = skills.find((skill) => skill.key === activeConversation?.lockedSkillKey);
useEffect(() => setSidebarOpen(!isMobile), [isMobile]);
const refreshConversations = useCallback(async () => {
const data = sortConversations(await aiChatApi.listConversations());
setConversations(data);
setActiveId((current) =>
current && data.some((item) => item.id === current) ? current : (data[0]?.id ?? null),
const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);
setConversations(items);
const current = activeConversationKey;
setActiveConversationKey(
current && items.some((item) => item.key === current) ? current : (items[0]?.key ?? ''),
);
}, []);
}, [activeConversationKey, setActiveConversationKey, setConversations]);
const provider = useMemo(
() =>
@@ -80,299 +141,398 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
[activeId, refreshConversations],
);
const { messages, onRequest, onReload, isRequesting, abort, setMessages } = useXChat<
const { messages, onRequest, onReload, isRequesting, abort, setMessage } = useXChat<
AiChatMessage,
AiChatMessage,
AiChatInput,
AiSseChunk
>({
provider,
conversationKey: activeId ? String(activeId) : 'no-conversation',
requestPlaceholder: {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
conversationKey: activeConversationKey || 'no-conversation',
defaultMessages: async () => {
if (!activeId) return [];
const page = await aiChatApi.listMessages(activeId);
return page.items.map(mapHistoryMessage);
},
requestPlaceholder: emptyAssistant(),
requestFallback: (
_params: Partial<AiChatInput>,
{
error,
messageInfo,
}: {
error: Error;
messageInfo: MessageInfo<AiChatMessage>;
messages: AiChatMessage[];
errorInfo?: unknown;
},
params: Partial<AiChatInput>,
{ error, messageInfo }: { error: Error; messageInfo: MessageInfo<AiChatMessage> },
) => ({
...(messageInfo?.message || {
role: 'assistant' as const,
content: '',
reasoningContent: '',
toolRuns: [],
}),
...(params.reloadMessage || messageInfo?.message || emptyAssistant()),
error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试',
cancelled: error.name === 'AbortError',
}),
});
requestingRef.current = isRequesting;
const abortRef = React.useRef(abort);
abortRef.current = abort;
attachmentsRef.current = attachments;
const stopRequest = useCallback(() => {
if (requestingRef.current) abortRef.current();
}, []);
const discardPendingAttachments = useCallback(() => {
const pending = attachmentsRef.current;
attachmentsRef.current = [];
setAttachments([]);
for (const attachment of pending) {
void aiChatApi.deleteAttachment(attachment.id).catch(() => undefined);
}
}, []);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoadingList(true);
aiChatApi
.listConversations()
.then(async (items) => {
Promise.all([aiChatApi.listSkills(), aiChatApi.listConversations()])
.then(async ([skillItems, conversationItems]) => {
if (cancelled) return;
let next = sortConversations(items);
if (next.length === 0) next = [await aiChatApi.createConversation()];
if (cancelled) return;
setConversations(next);
setActiveId((current) => current ?? next[0].id);
setSkills(skillItems);
let next = sortConversations(conversationItems);
if (!next.length) next = [await aiChatApi.createConversation()];
const data = next.map(toConversationData);
setConversations(data);
setActiveConversationKey(data[0]?.key ?? '');
})
.catch(() => message.error('加载 AI 会话失败'))
.catch(() => message.error('加载 AI 助手失败'))
.finally(() => !cancelled && setLoadingList(false));
return () => {
cancelled = true;
};
}, [open]);
}, [open, setActiveConversationKey, setConversations]);
useEffect(() => {
if (!open || !activeId) {
setMessages([]);
return;
}
let cancelled = false;
stopRequest();
setLoadingMessages(true);
aiChatApi
.listMessages(activeId)
.then((page) => {
if (!cancelled) setMessages(page.items.map(mapHistoryMessage));
})
.catch(() => !cancelled && message.error('加载会话记录失败'))
.finally(() => !cancelled && setLoadingMessages(false));
return () => {
cancelled = true;
};
}, [activeId, open, setMessages, stopRequest]);
discardPendingAttachments();
if (isMobile) setSidebarOpen(false);
}, [activeConversationKey, discardPendingAttachments, isMobile]);
const createConversation = async () => {
useEffect(() => () => stopRequest(), [stopRequest]);
const createConversation = useCallback(async () => {
try {
stopRequest();
const created = await aiChatApi.createConversation();
setConversations((items) => [created, ...items]);
setActiveId(created.id);
const created = toConversationData(await aiChatApi.createConversation());
addConversation(created, 'prepend');
setActiveConversationKey(created.key);
if (isMobile) setSidebarOpen(false);
} catch {
message.error('新建会话失败');
}
};
}, [addConversation, isMobile, setActiveConversationKey, stopRequest]);
const renameConversation = (conversation: AiConversation) => {
let title = conversation.title;
Modal.confirm({
title: '重命名会话',
icon: <EditOutlined />,
content: (
<Input
defaultValue={title}
maxLength={100}
onChange={(event) => (title = event.target.value)}
/>
),
okText: '保存',
cancelText: '取消',
onOk: async () => {
const normalized = title.trim();
if (!normalized) throw new Error('请输入会话名称');
const updated = await aiChatApi.renameConversation(conversation.id, normalized);
setConversations((items) => items.map((item) => (item.id === updated.id ? updated : item)));
},
});
};
const renameConversation = useCallback(
(conversation: ConversationData) => {
let title = conversation.title;
Modal.confirm({
title: '重命名会话',
icon: <EditOutlined />,
content: <Input defaultValue={title} maxLength={100} onChange={(event) => (title = event.target.value)} />,
okText: '保存',
cancelText: '取消',
onOk: async () => {
const normalized = title.trim();
if (!normalized) throw new Error('请输入会话名称');
const updated = toConversationData(
await aiChatApi.updateConversation(conversation.id, { title: normalized }),
);
setConversation(conversation.key, updated);
},
});
},
[setConversation],
);
const deleteConversation = (conversation: AiConversation) => {
Modal.confirm({
title: '删除会话',
content: '该会话及全部历史消息将被永久删除。',
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
const deleteConversation = useCallback(
(conversation: ConversationData) => {
Modal.confirm({
title: '删除会话',
content: '该会话及全部历史消息将被永久删除',
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (conversation.id === activeId) stopRequest();
await aiChatApi.deleteConversation(conversation.id);
const remaining = conversations.filter((item) => item.id !== conversation.id);
if (remaining.length > 0) {
setConversations(remaining);
if (conversation.id === activeId) setActiveId(remaining[0].id);
} else {
const created = await aiChatApi.createConversation();
setConversations([created]);
setActiveId(created.id);
removeConversation(conversation.key);
const remaining = conversations.filter((item) => item.key !== conversation.key);
if (!remaining.length) {
const created = toConversationData(await aiChatApi.createConversation());
addConversation(created, 'prepend');
setActiveConversationKey(created.key);
} else if (conversation.id === activeId) {
setActiveConversationKey(remaining[0].key);
}
} catch {
message.error('删除会话失败');
throw new Error('删除会话失败');
}
},
});
};
const submit = (value: string) => {
const normalized = value.trim();
if (!normalized || !activeId || isRequesting) return;
onRequest({ message: normalized });
setInput('');
};
const retryMessage = (assistantIndex: number, assistantId: string | number) => {
const previous = [...messages.slice(0, assistantIndex)]
.reverse()
.find((item) => item.message.role === 'user');
if (!previous?.message.content) return;
onReload(assistantId, { message: previous.message.content });
};
const conversationItems = conversations.map((item) => ({
key: String(item.id),
label: item.title,
}));
const bubbleItems: BubbleItemType[] = messages.map(
(item: MessageInfo<AiChatMessage>, index: number) => ({
key: item.id,
role: item.message.role,
status: item.status,
content: item.message,
streaming: item.status === 'loading' || item.status === 'updating',
loading:
item.message.role === 'assistant' &&
item.status === 'loading' &&
!item.message.content &&
!item.message.reasoningContent,
footer:
item.message.role === 'assistant' && (item.status === 'error' || item.message.error)
? () => (
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => retryMessage(index, item.id)}
>
</Button>
)
: undefined,
}),
},
});
},
[activeId, addConversation, conversations, removeConversation, setActiveConversationKey, stopRequest],
);
const conversationMenu = useCallback(
(item: ConversationItemType): MenuProps => ({
items: [
{ key: 'rename', label: '重命名', icon: <EditOutlined /> },
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true },
],
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
const conversation = conversations.find((entry) => entry.key === item.key) as ConversationData;
if (key === 'rename') renameConversation(conversation);
if (key === 'delete') deleteConversation(conversation);
},
}),
[conversations, deleteConversation, renameConversation],
);
const setLockedSkill = useCallback(
async (skillKey: string | null) => {
if (!activeConversation) return;
try {
const updated = toConversationData(
await aiChatApi.updateConversation(activeConversation.id, { lockedSkillKey: skillKey }),
);
setConversation(activeConversation.key, updated);
} catch {
message.error('切换技能失败');
}
},
[activeConversation, setConversation],
);
const submit = useCallback(
(value: string) => {
const text = value.trim();
if (!text || !activeId || isRequesting) return;
const submittedAttachments = attachmentsRef.current;
attachmentsRef.current = [];
onRequest({
message: text,
attachmentIds: submittedAttachments.map((item) => item.id),
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
localAttachments: submittedAttachments,
});
setInput('');
setAttachments([]);
}, [activeConversation?.lockedSkillKey, activeId, isRequesting, onRequest]);
const reloadMessage = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
if (!activeId || typeof messageInfo.message.id !== 'number') return;
onReload(messageInfo.id, {
message: '',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
regenerateMessageId: messageInfo.message.id,
reloadMessage: messageInfo.message,
});
},
[activeConversation?.lockedSkillKey, activeId, onReload],
);
const updateFeedback = useCallback(
async (messageInfo: MessageInfo<AiChatMessage>, feedback: 'like' | 'dislike' | null) => {
if (typeof messageInfo.message.id !== 'number') return;
try {
await aiChatApi.setFeedback(messageInfo.message.id, feedback);
setMessage(messageInfo.id, {
message: { ...messageInfo.message, feedback },
});
} catch {
message.error('提交反馈失败');
}
},
[setMessage],
);
const customUpload = useCallback<NonNullable<UploadProps['customRequest']>>(async (options) => {
const file = options.file as File;
if (attachmentsRef.current.length >= 5) {
const error = new Error('每条消息最多添加 5 个附件');
options.onError?.(error);
message.warning(error.message);
return;
}
try {
const uploaded = await aiChatApi.uploadAttachment(file);
setAttachments((items) => [...items, uploaded]);
options.onSuccess?.(uploaded, file);
} catch (error) {
options.onError?.(error instanceof Error ? error : new Error('附件上传失败'));
message.error('附件上传失败');
}
}, []);
const removeAttachment = useCallback(async (file: UploadFile<AiAttachment>) => {
const attachment = file.response;
if (!attachment) return true;
try {
await aiChatApi.deleteAttachment(attachment.id);
setAttachments((items) => items.filter((item) => item.id !== attachment.id));
return true;
} catch {
message.error('删除附件失败');
return false;
}
}, []);
const uploadItems = useMemo(() => attachments.map(toUploadFile), [attachments]);
const promptItems = useMemo<PromptsItemType[]>(
() =>
(lockedSkill ? [lockedSkill] : skills)
.flatMap((skill) => skill.examples.slice(0, lockedSkill ? 4 : 1).map((example) => ({ skill, example })))
.slice(0, 5)
.map(({ skill, example }) => ({
key: `${skill.key}-${example}`,
label: example,
description: skill.name,
})),
[lockedSkill, skills],
);
const bubbleItems = useMemo<BubbleItemType[]>(
() =>
messages.map((info) => ({
key: info.id,
role: info.message.role === 'assistant' ? 'assistant' : 'user',
status: info.status,
content: info.message,
contentRender: (content: AiChatMessage) => (
<AiMessageContent
message={content}
status={info.status as AiChatMessageStatus}
onReload={content.role === 'assistant' && info.status !== 'loading' ? () => reloadMessage(info) : undefined}
onFeedback={content.role === 'assistant' ? (feedback) => void updateFeedback(info, feedback) : undefined}
/>
),
})),
[messages, reloadMessage, updateFeedback],
);
const skillMenu: MenuProps = {
items: [
{ key: 'auto', label: '自动选择技能' },
{ type: 'divider' },
...skills.map((skill) => ({ key: skill.key, label: skill.name })),
],
selectedKeys: [activeConversation?.lockedSkillKey || 'auto'],
onClick: ({ key }) => void setLockedSkill(key === 'auto' ? null : key),
};
return (
<Drawer
title={
<div className="ai-chat-title">
<RobotOutlined />
<span>AI </span>
</div>
}
placement="right"
width={isMobile ? '100%' : 920}
title={<span className="ai-chat-title"><RobotOutlined /> AI </span>}
open={open}
onClose={() => {
stopRequest();
discardPendingAttachments();
onClose();
}}
destroyOnHidden
width={isMobile ? '100%' : 'min(1040px, 92vw)'}
destroyOnHidden={false}
className="ai-chat-drawer"
styles={{ body: { padding: 0 } }}
styles={{ body: { padding: 0, height: '100%' } }}
>
<div className="ai-chat-layout">
<aside className={`ai-chat-sidebar${sidebarOpen ? ' is-open' : ''}`}>
<Conversations
items={conversationItems}
activeKey={activeId ? String(activeId) : undefined}
onActiveChange={(key: ConversationItemType['key']) => {
setActiveId(Number(key));
if (isMobile) setSidebarOpen(false);
items={conversations as ConversationItemType[]}
activeKey={activeConversationKey}
onActiveChange={(key) => {
stopRequest();
setActiveConversationKey(key);
}}
menu={conversationMenu}
creation={{ label: '新对话', icon: <PlusOutlined />, onClick: createConversation }}
menu={(item: ConversationItemType) => {
const conversation = conversations.find(
(entry) => String(entry.id) === String(item.key),
);
return {
items: conversation
? [
{ key: 'rename', icon: <EditOutlined />, label: '重命名' },
{ key: 'delete', icon: <DeleteOutlined />, danger: true, label: '删除' },
]
: [],
onClick: ({ key, domEvent }: Parameters<NonNullable<MenuProps['onClick']>>[0]) => {
domEvent.stopPropagation();
if (!conversation) return;
if (key === 'rename') renameConversation(conversation);
if (key === 'delete') deleteConversation(conversation);
},
};
}}
/>
{loadingList && <Spin className="ai-chat-sidebar__loading" size="small" />}
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
</aside>
<section className="ai-chat-main">
<main className="ai-chat-main">
<div className="ai-chat-toolbar">
<Tooltip title={sidebarOpen ? '收起会话列表' : '展开会话列表'}>
<Tooltip title={sidebarOpen ? '收起会话' : '展开会话'}>
<Button
type="text"
icon={sidebarOpen ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
aria-label={sidebarOpen ? '收起会话列表' : '展开会话列表'}
onClick={() => setSidebarOpen((value) => !value)}
/>
</Tooltip>
<Typography.Text ellipsis>
{conversations.find((item) => item.id === activeId)?.title || 'AI 助理'}
</Typography.Text>
<Typography.Text ellipsis>{activeConversation?.title || 'AI 助手'}</Typography.Text>
<Dropdown menu={skillMenu} trigger={['click']}>
<Button size="small">{lockedSkill?.name || '自动技能'}</Button>
</Dropdown>
</div>
<div className="ai-chat-messages">
{loadingMessages ? (
<Spin />
) : bubbleItems.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="可以询问学生、班级、考勤、宿舍或账单情况"
/>
{messages.length ? (
<Bubble.List items={bubbleItems} role={aiBubbleRoles} autoScroll />
) : (
<Bubble.List
autoScroll
items={bubbleItems}
role={aiBubbleRoles}
/>
<div className="ai-chat-welcome">
<Welcome
variant="borderless"
icon={<RobotOutlined />}
title="你好,我是功学 AI 助手"
description={lockedSkill?.description || '我会在你的权限范围内查询学生、考勤、宿舍、账单和经营数据。'}
/>
<Prompts
title="你可以这样问"
items={promptItems}
wrap
onItemClick={({ data }) => submit(String(data.label || ''))}
/>
</div>
)}
</div>
<div className="ai-chat-composer">
<Sender
value={input}
onChange={setInput}
onSubmit={submit}
loading={isRequesting}
onSubmit={submit}
onCancel={stopRequest}
disabled={!activeId || loadingMessages}
placeholder="输入问题AI 将按您的业务权限查询"
autoSize={{ minRows: 1, maxRows: 5 }}
autoSize={{ minRows: 1, maxRows: 6 }}
placeholder="询问学生、考勤、宿舍或账单数据"
skill={
lockedSkill
? {
title: lockedSkill.name,
value: lockedSkill.key,
closable: { onClose: () => void setLockedSkill(null) },
}
: undefined
}
header={
uploadItems.length ? (
<Attachments
items={uploadItems}
customRequest={customUpload}
onRemove={removeAttachment}
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
multiple
/>
) : false
}
prefix={
<Attachments
items={[]}
customRequest={customUpload}
onRemove={removeAttachment}
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
multiple
placeholder={{ title: '添加附件', description: '图片、PDF、Word、Excel单个不超过 10MB' }}
>
<Button type="text" size="small"></Button>
</Attachments>
}
/>
<Typography.Text type="secondary" className="ai-chat-disclaimer">
AI 访
AI
</Typography.Text>
</div>
</section>
</main>
</div>
</Drawer>
);

View File

@@ -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>
);
};

View File

@@ -12,6 +12,7 @@ describe('AI chat API adapter', () => {
{
id: 1,
title: '会话',
lockedSkillKey: null,
createdAt: '2026-07-23T00:00:00.000Z',
updatedAt: '2026-07-23T00:00:00.000Z',
lastMessageAt: null,

View File

@@ -1,15 +1,48 @@
import api from '../../api';
import type { AiApiResponse, AiConversation, AiMessagePage } from './types';
import type {
AiApiResponse,
AiAttachment,
AiConversation,
AiMessageFeedback,
AiMessagePage,
AiSkill,
} from './types';
const basePath = '/ai/chat/conversations';
export const aiChatApi = {
listConversations: async () => (await api.get<AiApiResponse<AiConversation[]>>(basePath)).data,
createConversation: async (title?: string) =>
(await api.post<AiApiResponse<AiConversation>>(basePath, title ? { title } : {})).data,
renameConversation: async (id: number, title: string) =>
(await api.patch<AiApiResponse<AiConversation>>(`${basePath}/${id}`, { title })).data,
listSkills: async () => (await api.get<AiApiResponse<AiSkill[]>>('/ai/chat/skills')).data,
listConversations: async () =>
(await api.get<AiApiResponse<AiConversation[]>>(basePath)).data,
createConversation: async (input?: { title?: string; lockedSkillKey?: string | null }) =>
(await api.post<AiApiResponse<AiConversation>>(basePath, input ?? {})).data,
updateConversation: async (
id: number,
input: { title?: string; lockedSkillKey?: string | null },
) => (await api.patch<AiApiResponse<AiConversation>>(`${basePath}/${id}`, input)).data,
deleteConversation: (id: number) => api.delete<void>(`${basePath}/${id}`),
uploadAttachment: async (file: File): Promise<AiAttachment> => {
const form = new FormData();
form.append('file', file);
return (
await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000,
})
).data;
},
deleteAttachment: (id: number) => api.delete<void>(`/ai/chat/attachments/${id}`),
setFeedback: async (
messageId: number,
feedback: AiMessageFeedback,
reason?: string,
) =>
(
await api.patch<AiApiResponse<{ id: number; feedback: AiMessageFeedback }>>(
`/ai/chat/messages/${messageId}/feedback`,
{ feedback, reason },
)
).data,
listMessages: async (id: number): Promise<AiMessagePage> => {
const first = (
await api.get<AiApiResponse<AiMessagePage>>(`${basePath}/${id}/messages`, {
@@ -34,3 +67,7 @@ export const aiChatApi = {
export function conversationStreamUrl(id: number): string {
return `/api${basePath}/${id}/stream`;
}
export function regenerateStreamUrl(conversationId: number, messageId: number): string {
return `/api${basePath}/${conversationId}/messages/${messageId}/regenerate/stream`;
}

View File

@@ -22,6 +22,7 @@ describe('AI chat bubble rendering', () => {
content: '查询今天的系统概览',
reasoningContent: '',
toolRuns: [],
attachments: [],
};
container = document.createElement('div');
document.body.appendChild(container);

View File

@@ -11,6 +11,18 @@ describe('AI chat history mapper', () => {
status: 'completed',
errorCode: null,
createdAt: '2026-07-23T00:00:00.000Z',
feedback: 'like',
attachments: [
{
id: 8,
name: '考勤.pdf',
mimeType: 'application/pdf',
size: 100,
status: 'ready',
url: '/api/ai/chat/attachments/8',
createdAt: '2026-07-24T00:00:00.000Z',
},
],
toolRuns: [
{
toolCallId: 'tool-1',
@@ -24,6 +36,8 @@ describe('AI chat history mapper', () => {
expect(mapped.status).toBe('success');
expect(mapped.message.reasoningContent).toBe('思考');
expect(mapped.message.toolRuns[0].summary).toBe('共 4 间');
expect(mapped.message.attachments).toHaveLength(1);
expect(mapped.message.feedback).toBe('like');
});
it('maps failed and cancelled history to X SDK statuses', () => {

View File

@@ -26,6 +26,11 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMe
content: record.content || '',
reasoningContent: record.reasoningContent || '',
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
attachments: record.attachments ?? [],
replyToMessageId: record.replyToMessageId,
feedback: record.feedback,
feedbackReason: record.feedbackReason,
metadata: record.metadata,
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
cancelled: record.status === 'cancelled',
},

View File

@@ -39,6 +39,37 @@ describe('AI chat SSE message reducer', () => {
expect(message.toolRuns[0]).toMatchObject({ status: 'success', summary: '找到 1 条记录' });
});
it('tracks processed attachments and final feedback state', () => {
let message = reduceAiSseMessage(undefined, {
event: 'attachment.processed',
data: JSON.stringify({
attachment: {
id: 4,
name: '名单.xlsx',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 1200,
status: 'ready',
url: '/api/ai/chat/attachments/4',
createdAt: '2026-07-24T00:00:00.000Z',
},
}),
});
message = reduceAiSseMessage(message, {
event: 'message.completed',
data: JSON.stringify({
message: {
id: 12,
content: '完成',
reasoningContent: null,
feedback: 'like',
attachments: message.attachments,
},
}),
});
expect(message.attachments).toHaveLength(1);
expect(message.feedback).toBe('like');
});
it('uses final content and records cancellation and errors', () => {
let message = reduceAiSseMessage(undefined, {
event: 'message.completed',

View File

@@ -4,7 +4,13 @@ import {
type TransformMessage,
type XRequestOptions,
} from '@ant-design/x-sdk';
import type { AiChatInput, AiChatMessage, AiSseChunk, AiToolRun } from './types';
import type {
AiAttachment,
AiChatInput,
AiChatMessage,
AiSseChunk,
AiToolRun,
} from './types';
interface AiSsePayload {
messageId?: number;
@@ -15,9 +21,11 @@ interface AiSsePayload {
reasoningContent?: string | null;
toolCallId?: string;
toolName?: string;
skillKey?: string | null;
status?: string;
summary?: string | null;
durationMs?: number | null;
attachment?: AiAttachment;
message?:
| string
| {
@@ -26,6 +34,11 @@ interface AiSsePayload {
reasoningContent?: string | null;
status?: string;
toolRuns?: AiToolRun[];
attachments?: AiAttachment[];
replyToMessageId?: number | null;
feedback?: 'like' | 'dislike' | null;
feedbackReason?: string | null;
metadata?: Record<string, unknown> | null;
};
error?: string;
}
@@ -36,6 +49,7 @@ function emptyAssistant(): AiChatMessage {
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
};
}
@@ -66,6 +80,7 @@ function upsertToolRun(
const next: AiToolRun = {
toolCallId,
toolName: payload.toolName || '查询工具',
skillKey: payload.skillKey,
status: (payload.status as AiToolRun['status']) || fallbackStatus,
summary: payload.summary,
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
@@ -99,6 +114,11 @@ export function reduceAiSseMessage(
message.content = nested?.content ?? message.content;
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
message.feedback = nested?.feedback ?? message.feedback;
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
message.metadata = nested?.metadata ?? message.metadata;
} else if (event === 'reasoning.delta') {
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
} else if (event === 'content.delta') {
@@ -109,6 +129,10 @@ export function reduceAiSseMessage(
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success');
} else if (event === 'tool.failed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed');
} else if (event === 'attachment.processed' && payload.attachment) {
if (!message.attachments.some((item) => item.id === payload.attachment?.id)) {
message.attachments = [...message.attachments, payload.attachment];
}
} else if (event === 'message.completed') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.messageId ?? message.id;
@@ -116,6 +140,11 @@ export function reduceAiSseMessage(
message.reasoningContent =
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
message.feedback = nested?.feedback ?? message.feedback;
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
message.metadata = nested?.metadata ?? message.metadata;
} else if (event === 'message.cancelled') {
message.id = payload.messageId ?? message.id;
message.cancelled = true;
@@ -133,7 +162,31 @@ async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit):
const token = localStorage.getItem('token');
if (token) headers.set('Authorization', `Bearer ${token}`);
headers.set('Accept', 'text/event-stream');
const response = await fetch(input, { ...init, headers });
let requestInput = input;
let requestInit = init;
if (typeof init?.body === 'string') {
try {
const body = JSON.parse(init.body) as AiChatInput;
if (body.regenerateMessageId) {
requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.regenerateMessageId}/regenerate/stream`;
requestInit = {
...init,
body: JSON.stringify({ clientRequestId: body.clientRequestId }),
};
} else {
const {
localAttachments: _localAttachments,
reloadMessage: _reloadMessage,
regenerateMessageId: _regenerateMessageId,
...payload
} = body;
requestInit = { ...init, body: JSON.stringify(payload) };
}
} catch {
requestInit = init;
}
}
const response = await fetch(requestInput, { ...requestInit, headers });
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
@@ -171,6 +224,12 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
return {
...options.params,
message: requestParams.message?.trim() || '',
attachmentIds: requestParams.attachmentIds ?? [],
skillKey: requestParams.skillKey ?? null,
clientRequestId: requestParams.clientRequestId || crypto.randomUUID(),
localAttachments: requestParams.localAttachments,
regenerateMessageId: requestParams.regenerateMessageId,
reloadMessage: requestParams.reloadMessage,
};
}
@@ -180,6 +239,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
content: requestParams.message?.trim() || '',
reasoningContent: '',
toolRuns: [],
attachments: requestParams.localAttachments ?? [],
};
}

View File

@@ -9,6 +9,7 @@
}
.ai-chat-layout {
position: relative;
display: flex;
height: 100%;
min-height: 0;
@@ -38,6 +39,10 @@
overflow-y: auto;
}
.ai-chat-sidebar .ant-conversations-creation {
margin-bottom: 8px;
}
.ai-chat-sidebar__loading {
position: absolute;
inset: 68px 0 auto;
@@ -62,10 +67,22 @@
}
.ai-chat-toolbar .ant-typography {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
}
.ai-chat-welcome {
display: grid;
width: min(720px, 100%);
gap: 20px;
padding: 32px;
}
.ai-chat-welcome .ant-welcome-icon {
color: #007aff;
}
.ai-chat-messages {
display: flex;
flex: 1 1 auto;
@@ -91,6 +108,10 @@
white-space: pre-wrap;
}
.ai-chat-user-content {
max-width: 100%;
}
.ai-chat-answer {
width: 100%;
min-width: 0;
@@ -108,33 +129,11 @@
overflow-x: auto;
}
.ai-chat-tools {
display: grid;
gap: 6px;
padding: 8px 10px;
background: #f7f7f8;
border: 1px solid #ededf0;
border-radius: 8px;
}
.ai-chat-tool {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.ai-chat-tool .ant-tag {
flex: 0 0 auto;
margin: 0;
}
.ai-chat-tool__summary {
min-width: 0;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
.ai-chat-answer .ant-thought-chain {
padding: 10px 12px;
background: #f7f8fa;
border: 1px solid #eceef2;
border-radius: 10px;
}
.ai-chat-composer {
@@ -144,6 +143,15 @@
border-top: 1px solid #ededf0;
}
.ai-chat-composer .ant-sender {
max-width: 820px;
margin: 0 auto;
}
.ai-chat-composer .ant-attachments {
max-width: 820px;
}
.ai-chat-composer .ant-sender-input:focus,
.ai-chat-composer .ant-sender-input:focus-visible,
.ai-chat-composer .ant-sender-input:focus-within {

View File

@@ -1,11 +1,36 @@
export interface AiConversation {
id: number;
title: string;
lockedSkillKey: string | null;
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;
}
export interface AiSkillTool {
name: string;
description: string;
}
export interface AiSkill {
key: string;
name: string;
description: string;
examples: string[];
tools: AiSkillTool[];
}
export interface AiAttachment {
id: number;
name: string;
mimeType: string;
size: number;
status: 'processing' | 'ready' | 'failed';
error?: string | null;
url: string;
createdAt: string;
}
export type AiToolRunStatus =
| 'running'
| 'success'
@@ -18,6 +43,7 @@ export interface AiToolRun {
id?: number;
toolCallId: string;
toolName: string;
skillKey?: string | null;
status: AiToolRunStatus;
summary?: string | null;
argumentsSummary?: string | null;
@@ -26,6 +52,7 @@ export interface AiToolRun {
}
export type AiMessageRole = 'user' | 'assistant';
export type AiMessageFeedback = 'like' | 'dislike' | null;
export interface AiChatMessage {
id?: number | string;
@@ -33,6 +60,11 @@ export interface AiChatMessage {
content: string;
reasoningContent: string;
toolRuns: AiToolRun[];
attachments: AiAttachment[];
replyToMessageId?: number | null;
feedback?: AiMessageFeedback;
feedbackReason?: string | null;
metadata?: Record<string, unknown> | null;
error?: string;
cancelled?: boolean;
}
@@ -44,6 +76,11 @@ export interface AiMessageRecord {
reasoningContent: string | null;
status: 'pending' | 'completed' | 'failed' | 'cancelled';
errorCode: string | null;
replyToMessageId?: number | null;
feedback?: AiMessageFeedback;
feedbackReason?: string | null;
metadata?: Record<string, unknown> | null;
attachments?: AiAttachment[];
createdAt: string;
toolRuns?: AiToolRun[];
}
@@ -57,6 +94,12 @@ export interface AiMessagePage {
export interface AiChatInput {
message: string;
attachmentIds: number[];
skillKey: string | null;
clientRequestId: string;
localAttachments?: AiAttachment[];
regenerateMessageId?: number;
reloadMessage?: AiChatMessage;
}
export type AiChatMessageStatus = 'local' | 'loading' | 'updating' | 'success' | 'error' | 'abort';