feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具

This commit is contained in:
2026-08-05 17:11:00 +08:00
parent 644c35ce53
commit 0e6e3e2d96
64 changed files with 8395 additions and 6434 deletions

View File

@@ -0,0 +1,122 @@
import React from 'react';
import type { BubbleListProps } from '@ant-design/x';
import type { Attachment } from '@ant-design/x/es/attachments';
import type { MessageInfo } from '@ant-design/x-sdk';
import { Tooltip } from 'antd';
import type { AiAttachment, AiChatMessage, AiConversation } from './types';
export interface ConversationData extends AiConversation {
key: string;
label: string;
}
export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped';
export function conversationStatusMeta(status: ConversationRunStatus): {
label: string;
color: string;
} {
if (status === 'running') return { label: '生成中', color: 'processing' };
if (status === 'done') return { label: '已完成', color: 'success' };
if (status === 'error') return { label: '失败', color: 'error' };
return { label: '已停止', color: 'default' };
}
export function sortConversations(items: AiConversation[]): AiConversation[] {
return [...items].sort((a, b) => {
const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();
const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime();
return bTime - aTime;
});
}
export function toConversationData(item: AiConversation): ConversationData {
return { ...item, key: String(item.id), label: item.title };
}
export 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',
};
}
export function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
};
}
/**
* 当前会话内新发送的用户消息还没有服务端数字 ID本地为 msg_N 临时 key
* 但紧随其后的 AI 回答会携带 replyToMessageId可据此反推用户消息 ID。
*/
export function resolveUserMessageId(
info: MessageInfo<AiChatMessage>,
all: MessageInfo<AiChatMessage>[],
): number | null {
if (typeof info.message.id === 'number') return info.message.id;
const index = all.findIndex((item) => item.id === info.id);
if (index === -1) return null;
for (const item of all.slice(index + 1)) {
if (typeof item.message.replyToMessageId === 'number') {
return item.message.replyToMessageId;
}
}
return null;
}
export interface HoverActionItem {
key: string;
title: string;
icon: React.ReactNode;
danger?: boolean;
onClick: () => void;
}
/** Codex Desktop 风格hover 消息时在气泡外显示的纯图标操作,不包裹 Button */
export function MessageHoverActions({ items }: { items: HoverActionItem[] }) {
return (
<div className="ai-chat-hover-actions" role="toolbar" aria-label="消息操作">
{items.map((item) => (
<Tooltip key={item.key} title={item.title}>
<span
role="button"
tabIndex={0}
className={`ai-chat-hover-action${item.danger ? ' is-danger' : ''}`}
aria-label={item.title}
onClick={item.onClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
item.onClick();
}
}}
>
{item.icon}
</span>
</Tooltip>
))}
</div>
);
}
export const aiBubbleRoles: BubbleListProps['role'] = {
user: { placement: 'end', variant: 'filled', shape: 'corner' },
assistant: { placement: 'start', variant: 'borderless' },
};

View File

@@ -0,0 +1,232 @@
import React from 'react';
import {
CheckSquareOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PaperClipOutlined,
PlusOutlined,
} from '@ant-design/icons';
import Attachments from '@ant-design/x/es/attachments';
import Conversations from '@ant-design/x/es/conversations';
import Sender from '@ant-design/x/es/sender';
import type { ConversationItemType } from '@ant-design/x';
import type { AttachmentsProps } from '@ant-design/x/es/attachments';
import { Button, Dropdown, Spin, Tooltip, Typography } from 'antd';
import type { MenuProps } from 'antd';
import type { AiSkill } from './types';
export interface AiChatSidebarProps {
className?: string;
conversationItems: ConversationItemType[];
activeConversationKey?: string;
selectionMode: boolean;
selectedKeys: string[];
loadingList: boolean;
conversationCount: number;
onActiveChange: (key: string) => void;
menu?: MenuProps | ((item: ConversationItemType) => MenuProps);
onStartNewConversation: () => void;
onSelectAll: () => void;
onInvertSelection: () => void;
onDeleteSelected: () => void;
onExitSelectionMode: () => void;
onEnterSelectionMode: () => void;
}
export const AiChatSidebar: React.FC<AiChatSidebarProps> = ({
className,
conversationItems,
activeConversationKey,
selectionMode,
selectedKeys,
loadingList,
conversationCount,
onActiveChange,
menu,
onStartNewConversation,
onSelectAll,
onInvertSelection,
onDeleteSelected,
onExitSelectionMode,
onEnterSelectionMode,
}) => {
return (
<aside className={className ?? 'ai-chat-sidebar'}>
<Conversations
items={conversationItems}
activeKey={activeConversationKey}
onActiveChange={onActiveChange}
menu={selectionMode ? undefined : menu}
creation={
selectionMode
? undefined
: { label: '新对话', icon: <PlusOutlined />, onClick: onStartNewConversation }
}
/>
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
<div className="ai-chat-sidebar__footer">
{selectionMode ? (
<>
<span className="ai-chat-sidebar__selected-count">{selectedKeys.length} </span>
<Button size="small" type="text" onClick={onSelectAll}>
</Button>
<Button size="small" type="text" onClick={onInvertSelection}>
</Button>
<Button
size="small"
type="text"
danger
disabled={selectedKeys.length === 0}
onClick={onDeleteSelected}
>
</Button>
<Button size="small" type="text" onClick={onExitSelectionMode}>
</Button>
</>
) : (
<Button
size="small"
type="text"
icon={<CheckSquareOutlined />}
disabled={conversationCount === 0}
onClick={onEnterSelectionMode}
>
</Button>
)}
</div>
</aside>
);
};
export interface AiChatComposerProps {
conversationTitle: string;
input: string;
onChange: (value: string) => void;
isRequesting: boolean;
onSubmit: (value: string) => void;
onCancel: () => void;
uploadItems: AttachmentsProps['items'];
onCustomUpload: AttachmentsProps['customRequest'];
onRemoveAttachment: AttachmentsProps['onRemove'];
deepThinking: boolean;
onDeepThinkingChange: (value: boolean) => void;
lockedSkill?: AiSkill;
onClearSkill: () => void;
onToggleSidebar: () => void;
sidebarOpen: boolean;
skillMenu: MenuProps;
}
export const AiChatComposer: React.FC<AiChatComposerProps> = ({
conversationTitle,
input,
onChange,
isRequesting,
onSubmit,
onCancel,
uploadItems,
onCustomUpload,
onRemoveAttachment,
deepThinking,
onDeepThinkingChange,
lockedSkill,
onClearSkill,
onToggleSidebar,
sidebarOpen,
skillMenu,
}) => {
return (
<>
<div className="ai-chat-toolbar">
<Tooltip title={sidebarOpen ? '收起会话' : '展开会话'}>
<Button
type="text"
icon={sidebarOpen ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
onClick={onToggleSidebar}
/>
</Tooltip>
<Typography.Text ellipsis>{conversationTitle}</Typography.Text>
<Dropdown menu={skillMenu} trigger={['click']}>
<Button size="small">{lockedSkill?.name || '自动技能'}</Button>
</Dropdown>
</div>
<div className="ai-chat-composer">
<Sender
value={input}
onChange={onChange}
loading={isRequesting}
onSubmit={onSubmit}
onCancel={onCancel}
onKeyDown={(e) => {
// 中文输入法合成中的回车(确认候选词)不应触发发送。
// 浏览器在 compositionend 后仍会派发 Enter keydown
// 此时 Sender 内部的 composition 标记已失效,需用
// KeyboardEvent.isComposing / keyCode 229 兜底。
if (e.nativeEvent.isComposing || e.keyCode === 229) {
return false;
}
return undefined;
}}
autoSize={{ minRows: 1, maxRows: 6 }}
placeholder="询问学生、考勤、宿舍或账单数据"
skill={
lockedSkill
? {
title: lockedSkill.name,
value: lockedSkill.key,
closable: { onClose: onClearSkill },
}
: undefined
}
header={
(uploadItems ?? []).length > 0 && (
<div className="ai-chat-sender-header">
<Attachments
items={uploadItems}
customRequest={onCustomUpload}
onRemove={onRemoveAttachment}
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
multiple
/>
</div>
)
}
footer={
<div className="ai-chat-sender-footer">
<Tooltip title="添加附件">
<Attachments
items={[]}
customRequest={onCustomUpload}
onRemove={onRemoveAttachment}
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
multiple
placeholder={{
title: '添加附件',
description: '图片、PDF、Word、Excel单个不超过 10MB',
}}
>
<Button type="text" icon={<PaperClipOutlined />} aria-label="添加附件" />
</Attachments>
</Tooltip>
<Sender.Switch
checkedChildren="深度思考"
unCheckedChildren="普通"
value={deepThinking}
onChange={onDeepThinkingChange}
disabled={isRequesting}
/>
</div>
}
/>
<Typography.Text type="secondary" className="ai-chat-disclaimer">
AI
</Typography.Text>
</div>
</>
);
};

View File

@@ -1,152 +1,71 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CheckSquareOutlined,
DeleteOutlined,
EditOutlined,
ArrowRightOutlined,
LoadingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PaperClipOutlined,
PlusOutlined,
RobotOutlined,
} from '@ant-design/icons';
import Bubble from '@ant-design/x/es/bubble';
import Prompts from '@ant-design/x/es/prompts';
import Welcome from '@ant-design/x/es/welcome';
import type { ConversationItemType } from '@ant-design/x';
import { useXConversations } from '@ant-design/x-sdk';
import {
Attachments,
Bubble,
Conversations,
Prompts,
Sender,
SenderSwitch,
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,
App,
Checkbox,
Drawer,
Dropdown,
Grid,
Input,
Modal,
Spin,
Tooltip,
Typography,
} from 'antd';
import type { MenuProps, UploadFile, UploadProps } from 'antd';
import type { MenuProps } from 'antd';
import { message } from '../../ui/app-message';
import { useSettingsStore } from '../../store/settings/settingsStore';
import { aiChatApi, conversationStreamUrl } from './api';
import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider } from './provider';
import type {
AiAttachment,
AiChatInput,
AiChatMessage,
AiChatMessageStatus,
AiConversation,
AiFormSchema,
AiReviewSchema,
AiReviewSection,
AiReviewSectionType,
AiSkill,
AiSseChunk,
} from './types';
import { ImportWizardModal } from '../ImportWizard/ImportWizardModal';
import type { AiSkill } from './types';
import { useAiChatMessageActions } from './useAiChatMessageActions';
import { AiChatComposer, AiChatSidebar } from './AiChatDrawer.parts';
import {
aiBubbleRoles,
conversationStatusMeta,
sortConversations,
toConversationData,
type ConversationData,
type ConversationRunStatus,
} from './AiChatDrawer.helpers';
import './style.css';
export {
aiBubbleRoles,
conversationStatusMeta,
type ConversationData,
type ConversationRunStatus,
} from './AiChatDrawer.helpers';
interface AiChatDrawerProps {
open: boolean;
onClose: () => void;
onRequestingChange?: (working: boolean) => void;
}
interface ConversationData extends AiConversation {
key: string;
label: string;
}
export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped';
export function conversationStatusMeta(status: ConversationRunStatus): {
label: string;
color: string;
} {
if (status === 'running') return { label: '生成中', color: 'processing' };
if (status === 'done') return { label: '已完成', color: 'success' };
if (status === 'error') return { label: '失败', color: 'error' };
return { label: '已停止', color: 'default' };
}
function sortConversations(items: AiConversation[]): AiConversation[] {
return [...items].sort((a, b) => {
const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();
const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime();
return bTime - aTime;
});
}
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', shape: 'corner' },
assistant: { placement: 'start', variant: 'borderless' },
};
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
const { modal } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const [loadingList, setLoadingList] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
const [input, setInput] = useState('');
const effectiveSidebarOpen = isMobile ? false : sidebarOpen;
const [skills, setSkills] = useState<AiSkill[]>([]);
const [attachments, setAttachments] = useState<AiAttachment[]>([]);
const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking);
const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking);
const [conversationStatus, setConversationStatus] = useState<
Record<number, ConversationRunStatus>
>({});
const [importWizardRunId, setImportWizardRunId] = useState<string | null>(null);
const [selectionMode, setSelectionMode] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const requestingRef = useRef(false);
const abortRef = useRef<() => void>(() => undefined);
const attachmentsRef = useRef<AiAttachment[]>([]);
const requestAbortRef = useRef(new Map<number, () => void>());
const providersRef = useRef(new Map<number, GongxueAiChatProvider>());
const loadedRef = useRef(false);
const pendingDraftConversationIdRef = useRef<number | null>(null);
const {
conversations,
@@ -160,15 +79,16 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
const activeConversationKeyRef = useRef(activeConversationKey);
const activeConversation = useMemo(
() => conversations.find((item) => item.key === activeConversationKey) as ConversationData | undefined,
() =>
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);
activeConversationKeyRef.current = activeConversationKey;
useEffect(() => setSidebarOpen(!isMobile), [isMobile]);
const refreshConversations = useCallback(async () => {
const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);
setConversations(items);
@@ -193,115 +113,53 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
[],
);
const provider = useMemo(
() => {
if (!activeId) return undefined;
const existing = providersRef.current.get(activeId);
if (existing) return existing;
const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => {
void refreshConversations();
markConversationFinished(activeId, result);
});
providersRef.current.set(activeId, created);
return created;
},
[activeId, markConversationFinished, refreshConversations],
);
const provider = useMemo(() => {
if (!activeId) return undefined;
const existing = providersRef.current.get(activeId);
if (existing) return existing;
const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => {
void refreshConversations();
markConversationFinished(activeId, result);
});
providersRef.current.set(activeId, created);
return created;
}, [activeId, markConversationFinished, refreshConversations]);
const { messages, onRequest, onReload, isRequesting, abort, setMessage, queueRequest } = useXChat<
AiChatMessage,
AiChatMessage,
AiChatInput,
AiSseChunk
>({
const {
input,
setInput,
deepThinking,
setDeepThinking,
isRequesting,
messages,
stopRequest,
submit,
customUpload,
removeAttachment,
discardPendingAttachments,
uploadItems,
promptItems,
bubbleItems,
} = useAiChatMessageActions({
activeConversation,
activeId,
provider,
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> },
) => ({
...(params.reloadMessage || messageInfo?.message || emptyAssistant()),
error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试',
cancelled: error.name === 'AbortError',
}),
requestAbortRef,
markConversationRunning,
addConversation,
setActiveConversationKey,
refreshConversations,
skills,
lockedSkill,
setImportWizardRunId,
});
useEffect(() => {
if (!provider) return;
provider.onExternalReview = (messageId, review) => {
setMessage(messageId, (info) => ({
message: {
...info.message,
reviews: (info.message.reviews ?? []).some((item) => item.id === review.id)
? (info.message.reviews ?? []).map((item) => (item.id === review.id ? review : item))
: [...(info.message.reviews ?? []), review],
},
}));
};
}, [provider, setMessage]);
requestingRef.current = isRequesting;
abortRef.current = abort;
attachmentsRef.current = attachments;
// isRequesting 由 @ant-design/x-sdk 的 useXChat 内部维护且没有完成回调,
// 这里把它视为外部 SDK 状态做订阅转发,是 Effect 的合理用法。
useEffect(() => {
onRequestingChange?.(isRequesting);
}, [isRequesting, onRequestingChange]);
const stopRequest = useCallback(() => {
if (requestingRef.current) abortRef.current();
}, []);
const requestWithStatus = useCallback(
(params: AiChatInput) => {
if (!activeId || !provider) return;
requestAbortRef.current.set(activeId, () => provider.request.abort());
markConversationRunning(activeId);
onRequest(params);
},
[activeId, markConversationRunning, onRequest, provider],
);
const reloadWithStatus = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
if (!activeId || !provider || typeof messageInfo.message.id !== 'number') return;
requestAbortRef.current.set(activeId, () => provider.request.abort());
markConversationRunning(activeId);
onReload(messageInfo.id, {
message: '',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
regenerateMessageId: messageInfo.message.id,
reloadMessage: messageInfo.message,
});
},
[
activeConversation?.lockedSkillKey,
activeId,
deepThinking,
markConversationRunning,
onReload,
provider,
],
);
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 || loadedRef.current) return;
let cancelled = false;
@@ -322,10 +180,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
};
}, [open, setActiveConversationKey, setConversations]);
useEffect(() => {
discardPendingAttachments();
if (isMobile) setSidebarOpen(false);
}, [activeConversationKey, discardPendingAttachments, isMobile]);
const switchConversation = useCallback(
(key: string) => {
discardPendingAttachments();
if (isMobile) setSidebarOpen(false);
setActiveConversationKey(key);
},
[discardPendingAttachments, isMobile, setActiveConversationKey],
);
useEffect(
() => () => {
@@ -338,17 +200,22 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
/** 新建对话Codex 风格):先进入草稿态,发送第一条消息时才创建 session */
const startNewConversation = useCallback(() => {
setActiveConversationKey('');
if (isMobile) setSidebarOpen(false);
}, [isMobile, setActiveConversationKey]);
switchConversation('');
}, [switchConversation]);
const renameConversation = useCallback(
(conversation: ConversationData) => {
let title = conversation.title;
Modal.confirm({
modal.confirm({
title: '重命名会话',
icon: <EditOutlined />,
content: <Input defaultValue={title} maxLength={100} onChange={(event) => (title = event.target.value)} />,
content: (
<Input
defaultValue={title}
maxLength={100}
onChange={(event) => (title = event.target.value)}
/>
),
okText: '保存',
cancelText: '取消',
onOk: async () => {
@@ -383,7 +250,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
const deleteConversation = useCallback(
(conversation: ConversationData) => {
Modal.confirm({
modal.confirm({
title: '删除会话',
content: '该会话及全部历史消息将被永久删除。',
okText: '删除',
@@ -395,9 +262,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
removeConversation(conversation.key);
const remaining = conversations.filter((item) => item.key !== conversation.key);
if (!remaining.length) {
setActiveConversationKey('');
switchConversation('');
} else if (conversation.id === activeId) {
setActiveConversationKey(remaining[0].key);
switchConversation(remaining[0].key);
}
},
});
@@ -405,9 +272,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
[
activeId,
conversations,
switchConversation,
removeConversation,
removeConversationEntry,
setActiveConversationKey,
],
);
@@ -443,7 +310,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
selectedKeys.includes(item.key),
) as ConversationData[];
if (!selected.length) return;
Modal.confirm({
modal.confirm({
title: `删除选中的 ${selected.length} 个会话`,
content: '选中的会话及全部历史消息将被永久删除,此操作不可恢复。',
okText: '删除',
@@ -457,7 +324,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
setConversationStatus({});
await aiChatApi.deleteAllConversations();
setConversations([]);
setActiveConversationKey('');
switchConversation('');
} else {
for (const item of selected) removeConversationEntry(item);
const deletedKeys: string[] = [];
@@ -477,9 +344,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
const remaining = conversations.filter((item) => !deleted.has(item.key));
setConversations(remaining);
if (!remaining.length) {
setActiveConversationKey('');
switchConversation('');
} else if (activeId != null && !remaining.some((item) => item.id === activeId)) {
setActiveConversationKey(remaining[0].key);
switchConversation(remaining[0].key);
}
if (failedTitles.length) message.error(`删除失败:${failedTitles.join('、')}`);
}
@@ -493,7 +360,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
removeConversation,
removeConversationEntry,
selectedKeys,
setActiveConversationKey,
switchConversation,
setConversations,
]);
@@ -505,7 +372,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
],
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
const conversation = conversations.find((entry) => entry.key === item.key) as ConversationData;
const conversation = conversations.find(
(entry) => entry.key === item.key,
) as ConversationData;
if (key === 'rename') renameConversation(conversation);
if (key === 'delete') deleteConversation(conversation);
},
@@ -521,252 +390,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
await aiChatApi.updateConversation(activeConversation.id, { lockedSkillKey: skillKey }),
);
setConversation(activeConversation.key, updated);
} catch {
} catch (error) {
console.error('切换技能失败', error);
message.error('切换技能失败');
}
},
[activeConversation, setConversation],
);
const submit = useCallback(
(value: string) => {
const text = value.trim();
if (!text || isRequesting) return;
const submittedAttachments = attachmentsRef.current;
attachmentsRef.current = [];
setAttachments([]);
setInput('');
const params: AiChatInput = {
message: text,
attachmentIds: submittedAttachments.map((item) => item.id),
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
localAttachments: submittedAttachments,
};
if (activeId != null) {
requestWithStatus(params);
return;
}
// 草稿态:先创建 session再发送第一条消息
void (async () => {
try {
const created = toConversationData(await aiChatApi.createConversation());
addConversation(created, 'prepend');
pendingDraftConversationIdRef.current = created.id;
markConversationRunning(created.id);
// 通过 XChat 的队列机制发送:等会话 key 切换并加载完成后再真正发出,
// 保证消息写入新会话的 store界面能正常显示对话内容。
queueRequest(created.key, params);
setActiveConversationKey(created.key);
} catch {
message.error('创建会话失败,请重试');
attachmentsRef.current = submittedAttachments;
setAttachments(submittedAttachments);
setInput(text);
}
})();
},
[
activeConversation?.lockedSkillKey,
activeId,
addConversation,
deepThinking,
isRequesting,
markConversationRunning,
queueRequest,
requestWithStatus,
setActiveConversationKey,
],
);
// 草稿 session 创建完成、provider 就绪后注册中止句柄
useEffect(() => {
if (activeId == null || !provider) return;
if (activeId !== pendingDraftConversationIdRef.current) return;
pendingDraftConversationIdRef.current = null;
requestAbortRef.current.set(activeId, () => provider.request.abort());
}, [activeId, provider]);
const reloadMessage = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
reloadWithStatus(messageInfo);
},
[reloadWithStatus],
);
const submitForm = useCallback(
(form: AiFormSchema, values: Record<string, unknown>) => {
if (!activeId || isRequesting) return;
requestWithStatus({
message: '表单提交',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
formSubmission: { formId: form.id, values, formTitle: form.title },
});
},
[activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus],
);
const submitReview = useCallback(
(reviewId: string, reviewTitle?: string) => {
if (!activeId || isRequesting) return;
requestWithStatus({
message: '确认批量导入',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
reviewSubmission: { reviewId, reviewTitle },
});
},
[activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus],
);
const confirmReviewStep = useCallback(
async (
messageId: number | undefined,
reviewId: string,
sectionKey: AiReviewSection['key'],
): Promise<AiReviewSchema> => {
const updated = await aiChatApi.confirmReviewStep(reviewId, sectionKey);
const apply = (review: AiReviewSchema) => {
if (provider?.onExternalReview && typeof messageId === 'number') {
provider.onExternalReview(messageId, review);
} else if (typeof messageId === 'number') {
setMessage(messageId, (info) => {
const reviews = info.message.reviews ?? [];
const exists = reviews.some((item) => item.id === review.id);
return {
message: {
...info.message,
reviews: exists
? reviews.map((item) => (item.id === review.id ? review : item))
: [...reviews, review],
},
};
});
}
};
apply(updated);
return updated;
},
[provider, setMessage],
);
const confirmReviewGroup = useCallback(
async (
messageId: number | undefined,
reviewId: string,
type: AiReviewSectionType,
): Promise<AiReviewSchema> => {
const updated = await aiChatApi.confirmReviewGroup(reviewId, type);
if (provider?.onExternalReview && typeof messageId === 'number') {
provider.onExternalReview(messageId, updated);
} else if (typeof messageId === 'number') {
setMessage(messageId, (info) => {
const reviews = info.message.reviews ?? [];
const exists = reviews.some((item) => item.id === updated.id);
return {
message: {
...info.message,
reviews: exists
? reviews.map((item) => (item.id === updated.id ? updated : item))
: [...reviews, updated],
},
};
});
}
return updated;
},
[provider, setMessage],
);
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}
onSubmitForm={submitForm}
onSubmitReview={submitReview}
onConfirmReviewStep={confirmReviewStep}
onConfirmReviewGroup={confirmReviewGroup}
/>
),
})),
[confirmReviewGroup, confirmReviewStep, messages, reloadMessage, submitForm, submitReview, updateFeedback],
);
const conversationItems = useMemo<ConversationItemType[]>(
() =>
conversations.map((item) => {
@@ -822,83 +453,61 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
return (
<Drawer
title={<span className="ai-chat-title"><RobotOutlined /> AI </span>}
title={
<span className="ai-chat-title">
<RobotOutlined />
AI
</span>
}
open={open}
closeIcon={<ArrowRightOutlined title="收起到后台继续运行" />}
onClose={onClose}
width={isMobile ? '100%' : 'min(1040px, 92vw)'}
size={isMobile ? '100%' : 'min(1040px, 92vw)'}
destroyOnHidden={false}
className="ai-chat-drawer"
styles={{ body: { padding: 0, height: '100%' } }}
>
<div className="ai-chat-layout">
<aside className={`ai-chat-sidebar${sidebarOpen ? ' is-open' : ''}`}>
<Conversations
items={conversationItems}
activeKey={activeConversationKey}
onActiveChange={(key) => {
if (selectionMode) toggleConversationSelection(key);
else setActiveConversationKey(key);
}}
menu={selectionMode ? undefined : conversationMenu}
creation={
selectionMode
? undefined
: { label: '新对话', icon: <PlusOutlined />, onClick: startNewConversation }
}
/>
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
<div className="ai-chat-sidebar__footer">
{selectionMode ? (
<>
<span className="ai-chat-sidebar__selected-count">{selectedKeys.length} </span>
<Button size="small" type="text" onClick={selectAllConversations}>
</Button>
<Button size="small" type="text" onClick={invertConversationSelection}>
</Button>
<Button
size="small"
type="text"
danger
disabled={selectedKeys.length === 0}
onClick={deleteSelectedConversations}
>
</Button>
<Button size="small" type="text" onClick={exitSelectionMode}>
</Button>
</>
) : (
<Button
size="small"
type="text"
icon={<CheckSquareOutlined />}
disabled={conversations.length === 0}
onClick={enterSelectionMode}
>
</Button>
)}
</div>
</aside>
<AiChatSidebar
className={`ai-chat-sidebar${effectiveSidebarOpen ? ' is-open' : ''}`}
conversationItems={conversationItems}
activeConversationKey={activeConversationKey}
selectionMode={selectionMode}
selectedKeys={selectedKeys}
loadingList={loadingList}
conversationCount={conversations.length}
onActiveChange={(key) => {
if (selectionMode) toggleConversationSelection(key);
else switchConversation(key);
}}
menu={conversationMenu}
onStartNewConversation={startNewConversation}
onSelectAll={selectAllConversations}
onInvertSelection={invertConversationSelection}
onDeleteSelected={deleteSelectedConversations}
onExitSelectionMode={exitSelectionMode}
onEnterSelectionMode={enterSelectionMode}
/>
<main className="ai-chat-main">
<div className="ai-chat-toolbar">
<Tooltip title={sidebarOpen ? '收起会话' : '展开会话'}>
<Button
type="text"
icon={sidebarOpen ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
onClick={() => setSidebarOpen((value) => !value)}
/>
</Tooltip>
<Typography.Text ellipsis>{activeConversation?.title || 'AI 助手'}</Typography.Text>
<Dropdown menu={skillMenu} trigger={['click']}>
<Button size="small">{lockedSkill?.name || '自动技能'}</Button>
</Dropdown>
</div>
<AiChatComposer
input={input}
onChange={setInput}
isRequesting={isRequesting}
onSubmit={submit}
onCancel={stopRequest}
uploadItems={uploadItems}
onCustomUpload={customUpload}
onRemoveAttachment={removeAttachment}
deepThinking={deepThinking}
onDeepThinkingChange={setDeepThinking}
lockedSkill={lockedSkill}
onClearSkill={() => void setLockedSkill(null)}
onToggleSidebar={() => setSidebarOpen((value) => !value)}
sidebarOpen={effectiveSidebarOpen}
skillMenu={skillMenu}
conversationTitle={activeConversation?.title || 'AI 助手'}
/>
<div className="ai-chat-messages">
{messages.length ? (
@@ -909,7 +518,10 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
variant="borderless"
icon={<RobotOutlined />}
title="你好,我是恭学 AI 助手"
description={lockedSkill?.description || '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'}
description={
lockedSkill?.description ||
'我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'
}
/>
<Prompts
title="你可以这样问"
@@ -921,65 +533,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
)}
</div>
<div className="ai-chat-composer">
<Sender
value={input}
onChange={setInput}
loading={isRequesting}
onSubmit={submit}
onCancel={stopRequest}
autoSize={{ minRows: 1, maxRows: 6 }}
placeholder="询问学生、考勤、宿舍或账单数据"
skill={
lockedSkill
? {
title: lockedSkill.name,
value: lockedSkill.key,
closable: { onClose: () => void setLockedSkill(null) },
}
: undefined
}
header={
uploadItems.length > 0 && (
<div className="ai-chat-sender-header">
<Attachments
items={uploadItems}
customRequest={customUpload}
onRemove={removeAttachment}
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
multiple
/>
</div>
)
}
footer={
<div className="ai-chat-sender-footer">
<Tooltip title="添加附件">
<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" icon={<PaperClipOutlined />} aria-label="添加附件" />
</Attachments>
</Tooltip>
<SenderSwitch
checkedChildren="深度思考"
unCheckedChildren="普通"
value={deepThinking}
onChange={setDeepThinking}
disabled={isRequesting}
/>
</div>
}
{importWizardRunId !== null && (
<ImportWizardModal
key={importWizardRunId}
open
runId={importWizardRunId}
onClose={() => setImportWizardRunId(null)}
/>
<Typography.Text type="secondary" className="ai-chat-disclaimer">
AI
</Typography.Text>
</div>
)}
</main>
</div>
</Drawer>

View File

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

View File

@@ -1,12 +1,14 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { XCard, registerCatalog } from '@ant-design/x-card';
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
import { Button, Tag, Tooltip, Typography } from 'antd';
import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card';
import { Button, Spin, Tag, Tooltip, Typography } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import type { EChartsType } from 'echarts/core';
import ReactECharts, { type EChartsOption } from '../../components/ECharts';
import type { EChartsOption } from '../../components/ECharts';
import type { AiChartSchema } from './types';
// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取
const ReactECharts = lazy(() => import('../../components/ECharts'));
const CHART_CATALOG_ID = 'gongxue-chart-catalog';
registerCatalog({
@@ -41,117 +43,129 @@ const CHART_TYPE_LABELS: Record<string, string> = {
funnel: '漏斗图',
};
function buildOption(chart: AiChartSchema): EChartsOption {
function buildNameValueRows(chart: AiChartSchema): { name: string; value: number }[] {
const nameField = chart.columns[0]?.key ?? '';
const valueField = chart.columns[1]?.key ?? '';
return chart.rows.map((row) => ({
name: String(row[nameField] ?? ''),
value: numberValue(row[valueField]),
}));
}
function buildScatterOption(chart: AiChartSchema): EChartsOption {
const columns = chart.columns;
if (chart.chartType === 'scatter') {
const nameField = columns[0]?.key ?? '';
const xField = columns[1]?.key ?? '';
const yField = columns[2]?.key ?? '';
const data = chart.rows.map((row) => ({
name: String(row[nameField] ?? ''),
value: [numberValue(row[xField]), numberValue(row[yField])],
}));
return {
tooltip: {
trigger: 'item',
formatter: (params: unknown) => {
const item = params as { name?: string; value?: number[] };
const [x, y] = item.value ?? [];
return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`;
},
const nameField = columns[0]?.key ?? '';
const xField = columns[1]?.key ?? '';
const yField = columns[2]?.key ?? '';
const data = chart.rows.map((row) => ({
name: String(row[nameField] ?? ''),
value: [numberValue(row[xField]), numberValue(row[yField])],
}));
return {
tooltip: {
trigger: 'item',
formatter: (params: unknown) => {
const item = params as { name?: string; value?: number[] };
const [x, y] = item.value ?? [];
return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`;
},
grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true },
xAxis: { type: 'value', name: columns[1]?.title },
yAxis: { type: 'value', name: columns[2]?.title },
series: [{ type: 'scatter', symbolSize: 10, data }],
};
}
if (chart.chartType === 'radar') {
const seriesNameField = columns[0]?.key ?? '';
const indicatorColumns = columns.slice(1);
const indicators = indicatorColumns.map((column) => {
const values = chart.rows.map((row) => numberValue(row[column.key]));
const max = Math.max(1, ...values);
return { name: column.title, max: Math.ceil(max * 1.1) };
});
const seriesData = chart.rows.map((row) => ({
name: String(row[seriesNameField] ?? ''),
value: indicatorColumns.map((column) => numberValue(row[column.key])),
}));
return {
tooltip: { trigger: 'item' },
legend: { bottom: 0, type: 'scroll' },
radar: { indicator: indicators, radius: '65%' },
series: [{ type: 'radar', data: seriesData }],
};
}
if (chart.chartType === 'gauge') {
const nameField = columns[0]?.key ?? '';
const valueField = columns[1]?.key ?? '';
const maxField = columns[2]?.key;
const gauges = chart.rows.map((row) => ({
name: String(row[nameField] ?? ''),
value: numberValue(row[valueField]),
max: maxField ? Math.max(1, numberValue(row[maxField])) : 100,
}));
return {
series: gauges.map((gauge, index) => ({
type: 'gauge',
center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'],
radius: '75%',
min: 0,
max: gauge.max,
title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 },
detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] },
data: [{ value: gauge.value, name: gauge.name }],
})),
};
}
if (chart.chartType === 'funnel') {
const nameField = columns[0]?.key ?? '';
const valueField = columns[1]?.key ?? '';
const data = chart.rows.map((row) => ({
name: String(row[nameField] ?? ''),
value: numberValue(row[valueField]),
}));
return {
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
legend: { bottom: 0, type: 'scroll' },
series: [
{
type: 'funnel',
left: '10%',
top: 20,
bottom: 40,
width: '80%',
minSize: '20%',
label: { formatter: '{b}: {c}' },
data,
},
],
};
}
if (chart.chartType === 'pie') {
const nameField = columns[0]?.key ?? '';
const valueField = columns[1]?.key ?? '';
const data = chart.rows.map((row) => ({
name: String(row[nameField] ?? ''),
value: numberValue(row[valueField]),
}));
return {
tooltip: { trigger: 'item' },
legend: { bottom: 0, type: 'scroll' },
series: [
{
type: 'pie',
radius: ['35%', '68%'],
center: ['50%', '45%'],
data,
label: { formatter: '{b}: {c}' },
},
],
};
}
},
grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true },
xAxis: { type: 'value', name: columns[1]?.title },
yAxis: { type: 'value', name: columns[2]?.title },
series: [{ type: 'scatter', symbolSize: 10, data }],
};
}
function buildRadarOption(chart: AiChartSchema): EChartsOption {
const columns = chart.columns;
const seriesNameField = columns[0]?.key ?? '';
const indicatorColumns = columns.slice(1);
const indicators = indicatorColumns.map((column) => {
const values = chart.rows.map((row) => numberValue(row[column.key]));
const max = Math.max(1, ...values);
return { name: column.title, max: Math.ceil(max * 1.1) };
});
const seriesData = chart.rows.map((row) => ({
name: String(row[seriesNameField] ?? ''),
value: indicatorColumns.map((column) => numberValue(row[column.key])),
}));
return {
tooltip: { trigger: 'item' },
legend: { bottom: 0, type: 'scroll' },
radar: { indicator: indicators, radius: '65%' },
series: [{ type: 'radar', data: seriesData }],
};
}
function buildGaugeOption(chart: AiChartSchema): EChartsOption {
const columns = chart.columns;
const nameField = columns[0]?.key ?? '';
const valueField = columns[1]?.key ?? '';
const maxField = columns[2]?.key;
const gauges = chart.rows.map((row) => ({
name: String(row[nameField] ?? ''),
value: numberValue(row[valueField]),
max: maxField ? Math.max(1, numberValue(row[maxField])) : 100,
}));
return {
series: gauges.map((gauge, index) => ({
type: 'gauge',
center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'],
radius: '75%',
min: 0,
max: gauge.max,
title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 },
detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] },
data: [{ value: gauge.value, name: gauge.name }],
})),
};
}
function buildNameValueOption(chart: AiChartSchema): EChartsOption {
const data = buildNameValueRows(chart);
return chart.chartType === 'funnel'
? {
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
legend: { bottom: 0, type: 'scroll' },
series: [
{
type: 'funnel',
left: '10%',
top: 20,
bottom: 40,
width: '80%',
minSize: '20%',
label: { formatter: '{b}: {c}' },
data,
},
],
}
: {
tooltip: { trigger: 'item' },
legend: { bottom: 0, type: 'scroll' },
series: [
{
type: 'pie',
radius: ['35%', '68%'],
center: ['50%', '45%'],
data,
label: { formatter: '{b}: {c}' },
},
],
};
}
function buildOption(chart: AiChartSchema): EChartsOption {
if (chart.chartType === 'scatter') return buildScatterOption(chart);
if (chart.chartType === 'radar') return buildRadarOption(chart);
if (chart.chartType === 'gauge') return buildGaugeOption(chart);
if (chart.chartType === 'funnel' || chart.chartType === 'pie') return buildNameValueOption(chart);
return buildCategoryOption(chart);
}
function buildCategoryOption(chart: AiChartSchema): EChartsOption {
const columns = chart.columns;
const categoryField = columns[0]?.key ?? '';
const categories = chart.rows.map((row) => String(row[categoryField] ?? ''));
const series = columns.slice(1).map((column) => ({
@@ -223,11 +237,9 @@ const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
</Tooltip>
</span>
</div>
<ReactECharts
option={option}
style={{ width: '100%', height: 260 }}
onReady={setInstance}
/>
<Suspense fallback={<Spin size="small" />}>
<ReactECharts option={option} style={{ width: '100%', height: 260 }} onReady={setInstance} />
</Suspense>
</div>
);
};
@@ -291,5 +303,3 @@ export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
</div>
);
};
export default DynamicChart;

View File

@@ -1,7 +1,21 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { XCard, registerCatalog } from '@ant-design/x-card';
import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card';
import { Alert, Button, DatePicker, Flex, Form, Input, InputNumber, Select, Typography } from 'antd';
import {
XCard,
registerCatalog,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design/x-card';
import {
Alert,
Button,
DatePicker,
Flex,
Form,
Input,
InputNumber,
Select,
Typography,
} from 'antd';
import dayjs from 'dayjs';
import type { AiFormField, AiFormSchema } from './types';
@@ -47,7 +61,11 @@ function normalizeValues(
}
interface FormPreviewProps {
form?: AiFormSchema;
form?: AiFormSchema & {
submitting?: boolean;
submitted?: boolean;
error?: string | null;
};
disabled?: boolean;
onAction?: (name: string, context: Record<string, unknown>) => void;
}
@@ -58,18 +76,14 @@ interface FormPreviewProps {
* normalized values back through the `form:submit` action.
*/
const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) => {
const runtime = form as unknown as {
submitting?: boolean;
submitted?: boolean;
error?: string | null;
};
const submitting = Boolean(runtime.submitting);
const submitting = Boolean(form?.submitting);
const initialValues = useMemo(
() => Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])),
() =>
Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])),
[form?.fields],
);
if (!form) return null;
const finished = Boolean(runtime.submitted) || form.status === 'submitted';
const finished = Boolean(form.submitted) || form.status === 'submitted';
const handleFinish = (values: Record<string, unknown>) => {
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
@@ -124,17 +138,20 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
options={field.options}
/>
) : field.type === 'date' ? (
<DatePicker className="ai-chat-dynamic-form__date" placeholder={field.placeholder} />
<DatePicker
className="ai-chat-dynamic-form__date"
placeholder={field.placeholder}
/>
) : (
<Input placeholder={field.placeholder} />
)}
</Form.Item>
))}
{runtime.error && (
{form.error && (
<Alert
type="error"
showIcon
message={runtime.error}
title={form.error}
className="ai-chat-dynamic-form__error"
/>
)}
@@ -235,5 +252,3 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubm
</div>
);
};
export default DynamicForm;

View File

@@ -1,15 +1,35 @@
import React, { useEffect, useRef, useState } from 'react';
import { XCard, registerCatalog } from '@ant-design/x-card';
import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card';
import { Alert, Button, Flex, Popconfirm, Steps, Table, Tag, Typography } from 'antd';
import type { TableProps } from 'antd';
import type {
AiReviewRow,
AiReviewSchema,
AiReviewSection,
AiReviewSectionStatus,
AiReviewSectionType,
} from './types';
import {
XCard,
registerCatalog,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design/x-card';
import {
Alert,
Button,
Flex,
Popconfirm,
Steps,
Table,
Tag,
Typography,
type TableProps,
} from 'antd';
import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types';
import {
GROUP_STATUS_LABELS,
SECTION_ORDER,
SECTION_STATUS_LABELS,
SECTION_TYPE_LABELS,
dependencyHint,
groupSections,
groupStatus,
sectionCount,
sectionResultText,
sectionStatus,
sectionType,
} from './reviewSection';
const REVIEW_CATALOG_ID = 'gongxue-review-catalog';
@@ -35,125 +55,6 @@ function surfaceId(reviewId: string): string {
return `review-${reviewId}`;
}
const SECTION_TYPE_LABELS: Record<AiReviewSectionType, string> = {
students: '学生',
rooms: '宿舍',
transfers: '换宿',
checkins: '入住记录',
};
const SECTION_ORDER: AiReviewSectionType[] = [
'students',
'rooms',
'transfers',
'checkins',
];
const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionType[]> = {
students: [],
rooms: [],
transfers: ['students', 'rooms'],
checkins: [],
};
function sectionType(section: Pick<AiReviewSection, 'key' | 'type'>): AiReviewSectionType {
if (
section.type === 'students' ||
section.type === 'rooms' ||
section.type === 'transfers' ||
section.type === 'checkins'
) {
return section.type;
}
const key = section.key as AiReviewSectionType;
if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {
return key;
}
const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));
return prefix ?? 'students';
}
function sectionCount(section: AiReviewSection): number {
return section.rows.length;
}
function sectionStatus(section: AiReviewSection): AiReviewSectionStatus {
return section.status ?? 'pending';
}
function sectionResultText(section: AiReviewSection): string {
if (!section.resultSummary) return '';
try {
const parsed = JSON.parse(section.resultSummary) as { message?: unknown };
if (typeof parsed.message === 'string') return parsed.message;
} catch {
// Older data may store a plain text summary.
}
return section.resultSummary;
}
const SECTION_STATUS_LABELS: Record<AiReviewSectionStatus, string> = {
pending: '待确认',
submitted: '已导入',
failed: '失败',
skipped: '已跳过',
};
type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing';
const GROUP_STATUS_LABELS: Record<GroupStatus, string> = {
pending: '待确认',
partial: '部分完成',
submitted: '已导入',
failed: '失败',
importing: '导入中',
};
function groupSections(
sections: AiReviewSection[],
type: AiReviewSectionType,
): AiReviewSection[] {
return sections.filter((section) => sectionType(section) === type);
}
function groupStatus(
sections: AiReviewSection[],
type: AiReviewSectionType,
submittingKey: string | null,
submittingGroup: boolean,
activeType?: AiReviewSectionType,
): GroupStatus {
const items = groupSections(sections, type);
if (items.length === 0) return 'pending';
if (
(submittingGroup && type === activeType) ||
items.some((item) => submittingKey === item.key)
) {
return 'importing';
}
if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';
if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';
return 'partial';
}
function dependencyHint(
sections: AiReviewSection[],
type: AiReviewSectionType,
): { step: number; title: string } | null {
for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) {
const matches = groupSections(sections, dependencyType);
if (matches.length === 0) {
return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] };
}
for (const section of matches) {
if (sectionStatus(section) !== 'submitted') {
return { step: sections.indexOf(section), title: section.title };
}
}
}
return null;
}
function errorMessage(reason: unknown): string {
if (reason instanceof Error) return reason.message;
if (reason && typeof reason === 'object' && 'message' in reason) {
@@ -188,7 +89,14 @@ function SectionTable({ section }: { section: AiReviewSection }) {
}
interface ReviewPreviewProps {
review?: AiReviewSchema;
review?: AiReviewSchema & {
submitting?: boolean;
activeKey?: string;
activeType?: AiReviewSectionType;
submittingKey?: string | null;
submittingGroup?: boolean;
error?: string | null;
};
disabled?: boolean;
onAction?: (name: string, context: Record<string, unknown>) => void;
}
@@ -197,27 +105,19 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
if (!review) return null;
const submitted = review.status === 'submitted';
const expired = review.status === 'expired';
const runtime = review as unknown as {
submitting?: boolean;
activeKey?: string;
activeType?: string;
submittingKey?: string | null;
submittingGroup?: boolean;
error?: string | null;
};
const submitting = Boolean(runtime.submitting);
const submittingKey = runtime.submittingKey ?? null;
const submittingGroup = Boolean(runtime.submittingGroup);
const submitting = Boolean(review.submitting);
const submittingKey = review.submittingKey ?? null;
const submittingGroup = Boolean(review.submittingGroup);
const sections = review.sections;
const presentTypes = SECTION_ORDER.filter((type) =>
sections.some((section) => sectionType(section) === type),
);
const activeType = presentTypes.includes(runtime.activeType as AiReviewSectionType)
? (runtime.activeType as AiReviewSectionType)
const activeType = presentTypes.includes(review.activeType as AiReviewSectionType)
? (review.activeType as AiReviewSectionType)
: presentTypes[0];
if (!activeType) return null;
const activeSection =
sections.find((section) => section.key === runtime.activeKey) ??
sections.find((section) => section.key === review.activeKey) ??
groupSections(sections, activeType)[0];
const activeStatus = activeSection ? sectionStatus(activeSection) : 'pending';
const dependency =
@@ -289,7 +189,10 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
)}
<Steps
size="small"
current={Math.max(0, typeItems.findIndex((item) => item.key === activeType))}
current={Math.max(
0,
typeItems.findIndex((item) => item.key === activeType),
)}
items={typeItems.map((item) => ({
key: item.key,
title: item.title,
@@ -309,16 +212,18 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
{SECTION_TYPE_LABELS[activeType]} · {group.length} / {typeTotal}
</Typography.Text>
<Typography.Text type="secondary">
{GROUP_STATUS_LABELS[
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
]}
{
GROUP_STATUS_LABELS[
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
]
}
</Typography.Text>
</Flex>
{groupDep && (
<Alert
type="warning"
showIcon
message={
title={
groupDep.step === -1
? `${groupDep.title}」分表尚未生成或导入,请先确认前置步骤`
: `请先确认第 ${groupDep.step + 1} 步「${groupDep.title}`
@@ -339,11 +244,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
})
}
>
<Button
type="primary"
loading={submittingGroup}
disabled={!groupReady}
>
<Button type="primary" loading={submittingGroup} disabled={!groupReady}>
{groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) ===
'submitted'
? '已导入'
@@ -372,9 +273,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
wrap
gap={8}
className="ai-chat-review-card__sheet"
onClick={() =>
onAction?.('review:selectStep', { sectionKey: section.key })
}
onClick={() => onAction?.('review:selectStep', { sectionKey: section.key })}
>
<Flex vertical gap={2} style={{ minWidth: 160 }}>
<Typography.Text>
@@ -419,7 +318,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
<Alert
type="warning"
showIcon
message={`${activeSection.title}${activeSection.issues.length} 条待处理`}
title={`${activeSection.title}${activeSection.issues.length} 条待处理`}
description={
<ul className="ai-chat-review__issues">
{activeSection.issues.slice(0, 20).map((issue, issueIndex) => (
@@ -434,7 +333,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
<Alert
type="warning"
showIcon
message={
title={
dependency.step === -1
? `${dependency.title}」分表尚未生成或导入,请先确认前置步骤`
: `请先确认第 ${dependency.step + 1} 步「${dependency.title}`
@@ -453,7 +352,13 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
)}
</Flex>
)}
<Flex justify="space-between" align="center" wrap gap={8} className="ai-chat-review-card__footer">
<Flex
justify="space-between"
align="center"
wrap
gap={8}
className="ai-chat-review-card__footer"
>
<Typography.Text type="secondary">
{allRows} {allIssues.length}
</Typography.Text>
@@ -466,22 +371,18 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
disabled={submitting || anyRunning || disabled}
onConfirm={() => onAction?.('review:submit', { reviewId: review.id })}
>
<Button
type="primary"
loading={submitting}
disabled={disabled || anyRunning}
>
<Button type="primary" loading={submitting} disabled={disabled || anyRunning}>
</Button>
</Popconfirm>
)}
</Flex>
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />}
{runtime.error && (
{review.error && (
<Alert
type="error"
showIcon
message={runtime.error}
title={review.error}
className="ai-chat-review-card__step-error"
/>
)}
@@ -525,6 +426,8 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
const [submittingGroup, setSubmittingGroup] = useState(false);
const [activeKey, setActiveKey] = useState<string | undefined>(undefined);
const [activeType, setActiveType] = useState<AiReviewSectionType | undefined>(undefined);
const activeTypeRef = useRef<AiReviewSectionType | undefined>(activeType);
activeTypeRef.current = activeType;
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
const [error, setError] = useState<string | null>(null);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
@@ -537,7 +440,9 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
review.sections.some((section) => sectionType(section) === type),
);
const preferredType =
activeType && types.includes(activeType) ? activeType : types[0];
activeTypeRef.current && types.includes(activeTypeRef.current)
? activeTypeRef.current
: types[0];
setActiveType(preferredType);
setActiveKey((current) =>
current &&
@@ -547,7 +452,7 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
? current
: review.sections.find((section) => sectionType(section) === preferredType)?.key,
);
}, [activeType, review]);
}, [review]);
useEffect(() => {
const sid = surfaceId(localReview.id);
@@ -593,7 +498,16 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
},
});
setCommands([...cmds]);
}, [activeKey, activeType, disabled, error, localReview, submitting, submittingGroup, submittingKey]);
}, [
activeKey,
activeType,
disabled,
error,
localReview,
submitting,
submittingGroup,
submittingKey,
]);
const handleSubmit = async (reviewId: string) => {
if (submitting) return;
@@ -639,8 +553,7 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
const handleAction = (payload: ActionPayload) => {
const context = payload.context ?? {};
if (payload.name === 'review:submit') {
const reviewId =
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
void handleSubmit(reviewId);
return;
}
@@ -648,33 +561,27 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
const type = context.type as AiReviewSectionType | undefined;
if (type && SECTION_ORDER.includes(type)) {
setActiveType(type);
setActiveKey(
localReview.sections.find((section) => sectionType(section) === type)?.key,
);
setActiveKey(localReview.sections.find((section) => sectionType(section) === type)?.key);
}
return;
}
if (payload.name === 'review:selectStep') {
if (typeof context.sectionKey === 'string') {
const section = localReview.sections.find(
(item) => item.key === context.sectionKey,
);
const section = localReview.sections.find((item) => item.key === context.sectionKey);
setActiveKey(context.sectionKey);
if (section) setActiveType(sectionType(section));
}
return;
}
if (payload.name === 'review:confirmStep') {
const reviewId =
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
if (typeof context.sectionKey === 'string') {
void handleConfirmStep(reviewId, context.sectionKey);
}
return;
}
if (payload.name === 'review:confirmGroup') {
const reviewId =
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
const type = context.type as AiReviewSectionType | undefined;
if (type && SECTION_ORDER.includes(type)) {
void handleConfirmGroup(reviewId, type);
@@ -684,16 +591,10 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
return (
<div className="ai-chat-review">
<XCard.Box
components={{ ReviewPreview }}
commands={commands}
onAction={handleAction}
>
<XCard.Box components={{ ReviewPreview }} commands={commands} onAction={handleAction}>
<XCard.Card id={surfaceId(localReview.id)} />
</XCard.Box>
{error && <Alert type="error" showIcon message={error} className="ai-chat-review__error" />}
{error && <Alert type="error" showIcon title={error} className="ai-chat-review__error" />}
</div>
);
};
export default DynamicReview;

View File

@@ -0,0 +1,49 @@
import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light';
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
import css from 'react-syntax-highlighter/dist/esm/languages/prism/css';
// 只注册 AI 对话里常用的语言,避免 @ant-design/x 的 CodeHighlighter
// 把所有 prism 语言都打进主包
SyntaxHighlighter.registerLanguage('tsx', tsx);
SyntaxHighlighter.registerLanguage('typescript', typescript);
SyntaxHighlighter.registerLanguage('javascript', javascript);
SyntaxHighlighter.registerLanguage('json', json);
SyntaxHighlighter.registerLanguage('bash', bash);
SyntaxHighlighter.registerLanguage('shell', bash);
SyntaxHighlighter.registerLanguage('sql', sql);
SyntaxHighlighter.registerLanguage('css', css);
const SUPPORTED_LANGUAGES = new Set([
'tsx',
'typescript',
'javascript',
'json',
'bash',
'shell',
'sql',
'css',
]);
interface LiteCodeHighlighterProps {
lang?: string;
children: string;
}
export function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) {
const language = lang && SUPPORTED_LANGUAGES.has(lang) ? lang : undefined;
return (
<SyntaxHighlighter
language={language}
style={oneLight}
customStyle={{ margin: '12px 0', borderRadius: 8, fontSize: 13 }}
>
{children}
</SyntaxHighlighter>
);
}

View File

@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from 'react';
interface LiteMermaidProps {
children: string;
}
/**
* 轻量 Mermaid 渲染:动态 import mermaid只有出现 mermaid 代码块时才加载
* mermaid 及其解析器/图布局依赖,避免随 AI 抽屉主包一起加载。
*/
export function LiteMermaid({ children }: LiteMermaidProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return;
void (async () => {
try {
const mermaid = (await import('mermaid')).default;
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);
if (!cancelled) {
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
container.replaceChildren(doc.documentElement);
setError(null);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : '图表渲染失败');
}
}
})();
return () => {
cancelled = true;
};
}, [children]);
if (error) {
return (
<pre style={{ whiteSpace: 'pre-wrap', color: '#cf1322', fontSize: 12 }}>{children}</pre>
);
}
return <div ref={containerRef} className="ai-chat-mermaid" />;
}

View File

@@ -3,7 +3,6 @@ import type {
AiApiResponse,
AiAttachment,
AiConversation,
AiMessageFeedback,
AiMessagePage,
AiReviewSchema,
AiReviewSection,
@@ -15,8 +14,7 @@ const basePath = '/ai/chat/conversations';
export const aiChatApi = {
listSkills: async () => (await api.get<AiApiResponse<AiSkill[]>>('/ai/chat/skills')).data,
listConversations: async () =>
(await api.get<AiApiResponse<AiConversation[]>>(basePath)).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 (
@@ -26,6 +24,12 @@ export const aiChatApi = {
deleteConversation: (id: number) => api.delete<void>(`${basePath}/${id}`),
deleteAllConversations: async () =>
(await api.delete<{ success: boolean; data: { deleted: number } }>(basePath)).data,
deleteMessage: async (conversationId: number, messageId: number) =>
(
await api.delete<AiApiResponse<{ deletedIds: number[] }>>(
`${basePath}/${conversationId}/messages/${messageId}`,
)
).data,
uploadAttachment: async (file: File): Promise<AiAttachment> => {
const form = new FormData();
form.append('file', file);
@@ -37,17 +41,6 @@ export const aiChatApi = {
).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,
confirmReviewStep: async (
reviewId: string,
sectionKey: AiReviewSection['key'],
@@ -90,7 +83,3 @@ 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

@@ -11,7 +11,6 @@ describe('AI chat history mapper', () => {
status: 'completed',
errorCode: null,
createdAt: '2026-07-23T00:00:00.000Z',
feedback: 'like',
attachments: [
{
id: 8,
@@ -37,7 +36,6 @@ describe('AI chat history mapper', () => {
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

@@ -65,8 +65,6 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMe
reviews: historyReviews(record),
charts: historyCharts(record),
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

@@ -44,7 +44,7 @@ describe('AI chat SSE message reducer', () => {
expect(message.toolRuns[0]).toMatchObject({ status: 'success', summary: '找到 1 条记录' });
});
it('tracks processed attachments and final feedback state', () => {
it('tracks processed attachments and final message state', () => {
let message = reduceAiSseMessage(undefined, {
event: 'attachment.processed',
data: JSON.stringify({
@@ -66,13 +66,11 @@ describe('AI chat SSE message reducer', () => {
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', () => {

View File

@@ -35,6 +35,7 @@ interface AiSsePayload {
form?: AiFormSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
wizard?: unknown;
retry?: AiModelRetryInfo;
message?:
| string
@@ -46,8 +47,6 @@ interface AiSsePayload {
toolRuns?: AiToolRun[];
attachments?: AiAttachment[];
replyToMessageId?: number | null;
feedback?: 'like' | 'dislike' | null;
feedbackReason?: string | null;
metadata?: Record<string, unknown> | null;
};
error?: string;
@@ -79,29 +78,10 @@ function mergeForms(
return next;
}
function mergeReviews(
current: AiReviewSchema[] | undefined,
incoming: AiReviewSchema | AiReviewSchema[] | undefined,
): AiReviewSchema[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const index = next.findIndex((existing) => existing.id === item.id);
if (index === -1) {
next.push(item);
} else {
next[index] = item;
}
}
return next;
}
function mergeCharts(
current: AiChartSchema[] | undefined,
incoming: AiChartSchema | AiChartSchema[] | undefined,
): AiChartSchema[] {
function mergeById<T extends { id: string }>(
current: T[] | undefined,
incoming: T | T[] | undefined,
): T[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
@@ -165,6 +145,28 @@ function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRu
}));
}
function applyMessagePayload(
message: AiChatMessage,
nested: AiSsePayload['message'],
payload: AiSsePayload,
): void {
if (typeof nested !== 'object' || nested === null) return;
message.forms = mergeForms(
message.forms,
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeById<AiChartSchema>(
message.charts,
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
message.metadata = nested.metadata ?? message.metadata;
}
export function reduceAiSseMessage(
originMessage: AiChatMessage | undefined,
chunk?: AiSseChunk,
@@ -179,22 +181,7 @@ export function reduceAiSseMessage(
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
message.forms = mergeForms(
message.forms,
(nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeReviews(
message.reviews,
(nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeCharts(
message.charts,
(nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
message.feedback = nested?.feedback ?? message.feedback;
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
message.metadata = nested?.metadata ?? message.metadata;
applyMessagePayload(message, nested, payload);
} else if (event === 'reasoning.delta') {
message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
@@ -206,9 +193,11 @@ export function reduceAiSseMessage(
} else if (event === 'ui.form' && payload.form) {
message.forms = mergeForms(message.forms, payload.form);
} else if (event === 'ui.review' && payload.review) {
message.reviews = mergeReviews(message.reviews, payload.review);
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeCharts(message.charts, payload.chart);
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
} else if (event === 'ui.import_wizard' && payload.wizard) {
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
} else if (event === 'tool.started') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
} else if (event === 'tool.completed') {
@@ -227,22 +216,7 @@ export function reduceAiSseMessage(
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
message.forms = mergeForms(
message.forms,
(nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeReviews(
message.reviews,
(nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeCharts(
message.charts,
(nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
message.feedback = nested?.feedback ?? message.feedback;
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
message.metadata = nested?.metadata ?? message.metadata;
applyMessagePayload(message, nested, payload);
message.retrying = null;
} else if (event === 'message.cancelled') {
message.id = payload.messageId ?? message.id;
@@ -280,6 +254,16 @@ export async function authenticatedFetch(
reasoningEffort: body.reasoningEffort,
}),
};
} else if (body.editMessageId) {
requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.editMessageId}/edit/stream`;
requestInit = {
...init,
body: JSON.stringify({
content: body.message,
clientRequestId: body.clientRequestId,
reasoningEffort: body.reasoningEffort,
}),
};
} else if (body.formSubmission) {
requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/forms/${body.formSubmission.formId}/submit/stream`;
requestInit = {
@@ -304,6 +288,7 @@ export async function authenticatedFetch(
localAttachments: _localAttachments,
reloadMessage: _reloadMessage,
regenerateMessageId: _regenerateMessageId,
editMessageId: _editMessageId,
formSubmission: _formSubmission,
reviewSubmission: _reviewSubmission,
...payload
@@ -331,10 +316,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
/** Routes events that target another (already streamed) message. */
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
constructor(
url: string,
onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void,
) {
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
super({
request: XRequest<AiChatInput, AiSseChunk, AiChatMessage>(url, {
manual: true,
@@ -369,11 +351,16 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
formSubmission: requestParams.formSubmission,
reviewSubmission: requestParams.reviewSubmission,
regenerateMessageId: requestParams.regenerateMessageId,
editMessageId: requestParams.editMessageId,
reloadMessage: requestParams.reloadMessage,
};
}
transformLocalMessage(requestParams: Partial<AiChatInput>): AiChatMessage {
transformLocalMessage(requestParams: Partial<AiChatInput>): AiChatMessage | AiChatMessage[] {
if (requestParams.editMessageId) {
// 编辑消息不需要新增用户气泡store 里已原位更新原消息。
return [];
}
if (requestParams.formSubmission) {
return {
role: 'user',

View File

@@ -0,0 +1,115 @@
import type { AiReviewSection, AiReviewSectionStatus, AiReviewSectionType } from './types';
export const SECTION_TYPE_LABELS: Record<AiReviewSectionType, string> = {
students: '学生',
rooms: '宿舍',
transfers: '换宿',
checkins: '入住记录',
};
export const SECTION_ORDER: AiReviewSectionType[] = ['students', 'rooms', 'transfers', 'checkins'];
const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionType[]> = {
students: [],
rooms: [],
transfers: ['students', 'rooms'],
checkins: [],
};
export function sectionType(section: Pick<AiReviewSection, 'key' | 'type'>): AiReviewSectionType {
if (
section.type === 'students' ||
section.type === 'rooms' ||
section.type === 'transfers' ||
section.type === 'checkins'
) {
return section.type;
}
const key = section.key as AiReviewSectionType;
if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {
return key;
}
const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));
return prefix ?? 'students';
}
export function sectionCount(section: AiReviewSection): number {
return section.rows.length;
}
export function sectionStatus(section: AiReviewSection): AiReviewSectionStatus {
return section.status ?? 'pending';
}
export function sectionResultText(section: AiReviewSection): string {
if (!section.resultSummary) return '';
try {
const parsed = JSON.parse(section.resultSummary) as { message?: unknown };
if (typeof parsed.message === 'string') return parsed.message;
} catch {
// Older data may store a plain text summary.
}
return section.resultSummary;
}
export const SECTION_STATUS_LABELS: Record<AiReviewSectionStatus, string> = {
pending: '待确认',
submitted: '已导入',
failed: '失败',
skipped: '已跳过',
};
export type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing';
export const GROUP_STATUS_LABELS: Record<GroupStatus, string> = {
pending: '待确认',
partial: '部分完成',
submitted: '已导入',
failed: '失败',
importing: '导入中',
};
export function groupSections(
sections: AiReviewSection[],
type: AiReviewSectionType,
): AiReviewSection[] {
return sections.filter((section) => sectionType(section) === type);
}
export function groupStatus(
sections: AiReviewSection[],
type: AiReviewSectionType,
submittingKey: string | null,
submittingGroup: boolean,
activeType?: AiReviewSectionType,
): GroupStatus {
const items = groupSections(sections, type);
if (items.length === 0) return 'pending';
if (
(submittingGroup && type === activeType) ||
items.some((item) => submittingKey === item.key)
) {
return 'importing';
}
if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';
if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';
return 'partial';
}
export function dependencyHint(
sections: AiReviewSection[],
type: AiReviewSectionType,
): { step: number; title: string } | null {
for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) {
const matches = groupSections(sections, dependencyType);
if (matches.length === 0) {
return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] };
}
for (const section of matches) {
if (sectionStatus(section) !== 'submitted') {
return { step: sections.indexOf(section), title: section.title };
}
}
}
return null;
}

View File

@@ -207,10 +207,68 @@
padding: 20px clamp(16px, 4vw, 48px);
}
.ai-chat-messages .ant-bubble {
position: relative;
}
.ai-chat-messages .ant-bubble-content {
max-width: min(100%, 680px);
}
.ai-chat-messages .ant-bubble-extra {
position: absolute;
top: 2px;
right: 10px;
z-index: 2;
}
.ai-chat-hover-actions {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #eceef2;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
opacity: 0;
transform: translateY(-3px);
transition:
opacity 0.15s ease,
transform 0.15s ease;
pointer-events: none;
}
.ai-chat-messages .ant-bubble:hover .ai-chat-hover-actions,
.ai-chat-hover-actions:focus-within {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.ai-chat-hover-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
color: #5f6672;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.ai-chat-hover-action:hover {
background: #f0f2f5;
color: #1f2329;
}
.ai-chat-hover-action.is-danger:hover {
background: #fff1f0;
color: #cf1322;
}
.ai-chat-user-text {
max-width: 100%;
overflow-wrap: anywhere;
@@ -221,6 +279,10 @@
max-width: 100%;
}
.ai-chat-user-edit {
width: min(520px, 100%);
}
.ai-chat-answer {
width: 100%;
min-width: 0;

View File

@@ -98,6 +98,23 @@ export interface AiChartSchema {
rows: AiReviewRow[];
}
export interface AiImportWizard {
runId: string;
fileName: string;
sheets: Array<{
name: string;
suggestedStepKey?: AiReviewSectionType | null;
headers: string[];
rowCount: number;
}>;
steps: Array<{
stepKey: AiReviewSectionType;
label: string;
sheets: string[];
status: string;
}>;
}
export type AiToolRunStatus =
| 'running'
| 'success'
@@ -126,7 +143,6 @@ export interface AiModelRetryInfo {
}
export type AiMessageRole = 'user' | 'assistant';
export type AiMessageFeedback = 'like' | 'dislike' | null;
export interface AiChatMessage {
id?: number | string;
@@ -139,8 +155,6 @@ export interface AiChatMessage {
reviews?: AiReviewSchema[];
charts?: AiChartSchema[];
replyToMessageId?: number | null;
feedback?: AiMessageFeedback;
feedbackReason?: string | null;
metadata?: Record<string, unknown> | null;
retrying?: AiModelRetryInfo | null;
error?: string;
@@ -155,8 +169,6 @@ export interface AiMessageRecord {
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;
@@ -176,6 +188,7 @@ export interface AiChatInput {
skillKey: string | null;
clientRequestId: string;
reasoningEffort?: string | null;
editMessageId?: number;
localAttachments?: AiAttachment[];
formSubmission?: {
formId: string;

View File

@@ -0,0 +1,574 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
import { CopyOutlined, DeleteOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons';
import type { BubbleItemType, PromptsItemType } from '@ant-design/x';
import { useXChat, type MessageInfo } from '@ant-design/x-sdk';
import { App } from 'antd';
import type { UploadFile, UploadProps } from 'antd';
import { message } from '../../ui/app-message';
import { useSettingsStore } from '../../store/settings/settingsStore';
import { aiChatApi } from './api';
import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider } from './provider';
import {
emptyAssistant,
MessageHoverActions,
resolveUserMessageId,
toConversationData,
toUploadFile,
type ConversationData,
} from './AiChatDrawer.helpers';
import type {
AiAttachment,
AiChatInput,
AiChatMessage,
AiChatMessageStatus,
AiFormSchema,
AiReviewSchema,
AiReviewSection,
AiReviewSectionType,
AiSkill,
AiSseChunk,
} from './types';
interface UseAiChatMessageActionsParams {
activeConversation: ConversationData | undefined;
activeId: number | null;
provider: GongxueAiChatProvider | undefined;
requestAbortRef: MutableRefObject<Map<number, () => void>>;
markConversationRunning: (conversationId: number) => void;
addConversation: (conversation: ConversationData, placement?: 'prepend' | 'append') => boolean;
setActiveConversationKey: (key: string) => boolean;
refreshConversations: () => Promise<void>;
skills: AiSkill[];
lockedSkill: AiSkill | undefined;
setImportWizardRunId: (runId: string | null) => void;
}
export function useAiChatMessageActions({
activeConversation,
activeId,
provider,
requestAbortRef,
markConversationRunning,
addConversation,
setActiveConversationKey,
refreshConversations,
skills,
lockedSkill,
setImportWizardRunId,
}: UseAiChatMessageActionsParams) {
const { modal } = App.useApp();
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<AiAttachment[]>([]);
const [editingMessageId, setEditingMessageId] = useState<number | string | null>(null);
const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking);
const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking);
const requestingRef = useRef(false);
const abortRef = useRef<() => void>(() => undefined);
const attachmentsRef = useRef<AiAttachment[]>([]);
const pendingDraftConversationIdRef = useRef<number | null>(null);
const messagesRef = useRef<MessageInfo<AiChatMessage>[]>([]);
const {
messages,
onRequest,
onReload,
isRequesting,
abort,
setMessage,
removeMessage,
queueRequest,
} = useXChat<AiChatMessage, AiChatMessage, AiChatInput, AiSseChunk>({
provider,
conversationKey: activeConversation?.key || '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> },
) => ({
...(params.reloadMessage || messageInfo?.message || emptyAssistant()),
error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试',
cancelled: error.name === 'AbortError',
}),
});
useEffect(() => {
if (!provider) return;
provider.onExternalReview = (messageId, review) => {
setMessage(messageId, (info) => ({
message: {
...info.message,
reviews: (info.message.reviews ?? []).some((item) => item.id === review.id)
? (info.message.reviews ?? []).map((item) => (item.id === review.id ? review : item))
: [...(info.message.reviews ?? []), review],
},
}));
};
}, [provider, setMessage]);
requestingRef.current = isRequesting;
abortRef.current = abort;
attachmentsRef.current = attachments;
messagesRef.current = messages;
const stopRequest = useCallback(() => {
if (requestingRef.current) abortRef.current();
}, []);
const requestWithStatus = useCallback(
(params: AiChatInput) => {
if (!activeId || !provider) return;
requestAbortRef.current.set(activeId, () => provider.request.abort());
markConversationRunning(activeId);
onRequest(params);
},
[activeId, markConversationRunning, onRequest, provider, requestAbortRef],
);
const reloadWithStatus = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
if (!activeId || !provider || typeof messageInfo.message.id !== 'number') return;
requestAbortRef.current.set(activeId, () => provider.request.abort());
markConversationRunning(activeId);
onReload(messageInfo.id, {
message: '',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
regenerateMessageId: messageInfo.message.id,
reloadMessage: messageInfo.message,
});
},
[
activeConversation?.lockedSkillKey,
activeId,
deepThinking,
markConversationRunning,
onReload,
provider,
requestAbortRef,
],
);
const discardPendingAttachments = useCallback(() => {
const pending = attachmentsRef.current;
attachmentsRef.current = [];
setAttachments([]);
for (const attachment of pending) {
void aiChatApi.deleteAttachment(attachment.id).catch(() => undefined);
}
}, []);
const submit = useCallback(
(value: string) => {
const text = value.trim();
if (!text || isRequesting) return;
const submittedAttachments = attachmentsRef.current;
attachmentsRef.current = [];
setAttachments([]);
setInput('');
const params: AiChatInput = {
message: text,
attachmentIds: submittedAttachments.map((item) => item.id),
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
localAttachments: submittedAttachments,
};
if (activeId != null) {
requestWithStatus(params);
return;
}
// 草稿态:先创建 session再发送第一条消息
void (async () => {
try {
const created = toConversationData(await aiChatApi.createConversation());
addConversation(created, 'prepend');
pendingDraftConversationIdRef.current = created.id;
markConversationRunning(created.id);
// 通过 XChat 的队列机制发送:等会话 key 切换并加载完成后再真正发出,
// 保证消息写入新会话的 store界面能正常显示对话内容。
queueRequest(created.key, params);
setActiveConversationKey(created.key);
} catch {
message.error('创建会话失败,请重试');
attachmentsRef.current = submittedAttachments;
setAttachments(submittedAttachments);
setInput(text);
}
})();
},
[
activeConversation?.lockedSkillKey,
activeId,
addConversation,
deepThinking,
isRequesting,
markConversationRunning,
queueRequest,
requestWithStatus,
setActiveConversationKey,
],
);
// 草稿 session 创建完成、provider 就绪后注册中止句柄
useEffect(() => {
if (activeId == null || !provider) return;
if (activeId !== pendingDraftConversationIdRef.current) return;
pendingDraftConversationIdRef.current = null;
requestAbortRef.current.set(activeId, () => provider.request.abort());
}, [activeId, provider, requestAbortRef]);
const reloadMessage = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
reloadWithStatus(messageInfo);
},
[reloadWithStatus],
);
const copyMessage = useCallback((message: AiChatMessage) => {
if (!message.content) return;
void navigator.clipboard.writeText(message.content);
}, []);
const confirmDeleteMessage = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
if (!activeId || isRequesting) return;
const messageId = resolveUserMessageId(messageInfo, messagesRef.current);
if (messageId == null) return;
const scopeLabel =
messageInfo.message.role === 'user' ? '这条消息及其 AI 回答' : '这条 AI 回答';
modal.confirm({
title: '删除消息',
content: `将删除${scopeLabel},此操作不可恢复。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
const result = await aiChatApi.deleteMessage(activeId, messageId);
const storeIds = new Map<number, number | string>();
for (const item of messagesRef.current) {
if (typeof item.message.id === 'number') {
storeIds.set(item.message.id, item.id);
}
}
// 当前会话内新发送的用户消息没有服务端 ID但可映射到本地 msg_N key
storeIds.set(messageId, messageInfo.id);
for (const id of result.deletedIds) removeMessage(storeIds.get(id) ?? id);
void refreshConversations();
} catch (error) {
console.error('删除消息失败', error);
message.error('删除消息失败');
}
},
});
},
[activeId, isRequesting, refreshConversations, removeMessage],
);
const confirmEditMessage = useCallback(
(messageInfo: MessageInfo<AiChatMessage>, value: string) => {
if (!activeId) return;
const content = value.trim();
if (!content) {
message.warning('消息内容不能为空');
return;
}
const messageId = resolveUserMessageId(messageInfo, messagesRef.current);
if (messageId == null) {
message.warning('消息尚未同步,请稍后重试');
return;
}
setEditingMessageId(null);
if (content === messageInfo.message.content) return;
setMessage(messageInfo.id, (info) => ({
message: {
...info.message,
content,
metadata: { ...info.message.metadata, edited: true },
},
}));
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
if (index >= 0) {
for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id);
}
requestWithStatus({
message: content,
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
editMessageId: messageId,
});
},
[
activeConversation?.lockedSkillKey,
activeId,
deepThinking,
removeMessage,
requestWithStatus,
setMessage,
],
);
const submitForm = useCallback(
(form: AiFormSchema, values: Record<string, unknown>) => {
if (!activeId || isRequesting) return;
requestWithStatus({
message: '表单提交',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
formSubmission: { formId: form.id, values, formTitle: form.title },
});
},
[activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus],
);
const submitReview = useCallback(
(reviewId: string, reviewTitle?: string) => {
if (!activeId || isRequesting) return;
requestWithStatus({
message: '确认批量导入',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
reviewSubmission: { reviewId, reviewTitle },
});
},
[activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus],
);
const confirmReviewStep = useCallback(
async (
messageId: number | undefined,
reviewId: string,
sectionKey: AiReviewSection['key'],
): Promise<AiReviewSchema> => {
const updated = await aiChatApi.confirmReviewStep(reviewId, sectionKey);
const apply = (review: AiReviewSchema) => {
if (provider?.onExternalReview && typeof messageId === 'number') {
provider.onExternalReview(messageId, review);
} else if (typeof messageId === 'number') {
setMessage(messageId, (info) => {
const reviews = info.message.reviews ?? [];
const exists = reviews.some((item) => item.id === review.id);
return {
message: {
...info.message,
reviews: exists
? reviews.map((item) => (item.id === review.id ? review : item))
: [...reviews, review],
},
};
});
}
};
apply(updated);
return updated;
},
[provider, setMessage],
);
const confirmReviewGroup = useCallback(
async (
messageId: number | undefined,
reviewId: string,
type: AiReviewSectionType,
): Promise<AiReviewSchema> => {
const updated = await aiChatApi.confirmReviewGroup(reviewId, type);
if (provider?.onExternalReview && typeof messageId === 'number') {
provider.onExternalReview(messageId, updated);
} else if (typeof messageId === 'number') {
setMessage(messageId, (info) => {
const reviews = info.message.reviews ?? [];
const exists = reviews.some((item) => item.id === updated.id);
return {
message: {
...info.message,
reviews: exists
? reviews.map((item) => (item.id === updated.id ? updated : item))
: [...reviews, updated],
},
};
});
}
return updated;
},
[provider, 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,
extra:
info.status !== 'loading' && info.status !== 'updating' && !isRequesting ? (
info.message.role === 'user' ? (
editingMessageId === info.id ? undefined : (
<MessageHoverActions
items={[
{
key: 'copy',
title: '复制',
icon: <CopyOutlined />,
onClick: () => copyMessage(info.message),
},
{
key: 'edit',
title: '编辑',
icon: <EditOutlined />,
onClick: () => setEditingMessageId(info.id),
},
{
key: 'delete',
title: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => void confirmDeleteMessage(info),
},
]}
/>
)
) : (
<MessageHoverActions
items={[
{
key: 'copy',
title: '复制',
icon: <CopyOutlined />,
onClick: () => copyMessage(info.message),
},
{
key: 'reload',
title: '重新生成',
icon: <ReloadOutlined />,
onClick: () => reloadMessage(info),
},
]}
/>
)
) : undefined,
contentRender: (content: AiChatMessage) => (
<AiMessageContent
message={content}
status={info.status as AiChatMessageStatus}
editing={content.role === 'user' && editingMessageId === info.id}
onEditConfirm={
content.role === 'user' ? (value) => confirmEditMessage(info, value) : undefined
}
onEditCancel={content.role === 'user' ? () => setEditingMessageId(null) : undefined}
onSubmitForm={submitForm}
onSubmitReview={submitReview}
onConfirmReviewStep={confirmReviewStep}
onConfirmReviewGroup={confirmReviewGroup}
onOpenImportWizard={setImportWizardRunId}
/>
),
})),
[
copyMessage,
confirmDeleteMessage,
confirmEditMessage,
confirmReviewGroup,
confirmReviewStep,
editingMessageId,
isRequesting,
messages,
reloadMessage,
setImportWizardRunId,
submitForm,
submitReview,
],
);
return {
input,
setInput,
attachments,
setAttachments,
editingMessageId,
setEditingMessageId,
deepThinking,
setDeepThinking,
isRequesting,
messages,
stopRequest,
submit,
reloadMessage,
copyMessage,
confirmDeleteMessage,
confirmEditMessage,
submitForm,
submitReview,
confirmReviewStep,
confirmReviewGroup,
customUpload,
removeAttachment,
discardPendingAttachments,
uploadItems,
promptItems,
bubbleItems,
};
}