feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取

This commit is contained in:
2026-08-04 14:41:40 +08:00
parent f07ffdc64c
commit 50c44e4410
51 changed files with 11588 additions and 175 deletions

View File

@@ -1,9 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CheckSquareOutlined,
DeleteOutlined,
EditOutlined,
ArrowRightOutlined,
LoadingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PaperClipOutlined,
PlusOutlined,
RobotOutlined,
} from '@ant-design/icons';
@@ -13,6 +17,7 @@ import {
Conversations,
Prompts,
Sender,
SenderSwitch,
Welcome,
} from '@ant-design/x';
import type {
@@ -23,9 +28,21 @@ import type {
} from '@ant-design/x';
import type { Attachment } from '@ant-design/x/es/attachments';
import { useXChat, useXConversations, type MessageInfo } from '@ant-design/x-sdk';
import { Button, Drawer, Dropdown, Grid, Input, Modal, Spin, Tooltip, Typography } from 'antd';
import {
Button,
Checkbox,
Drawer,
Dropdown,
Grid,
Input,
Modal,
Spin,
Tooltip,
Typography,
} from 'antd';
import type { MenuProps, UploadFile, UploadProps } 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';
@@ -36,6 +53,10 @@ import type {
AiChatMessage,
AiChatMessageStatus,
AiConversation,
AiFormSchema,
AiReviewSchema,
AiReviewSection,
AiReviewSectionType,
AiSkill,
AiSseChunk,
} from './types';
@@ -44,6 +65,7 @@ import './style.css';
interface AiChatDrawerProps {
open: boolean;
onClose: () => void;
onRequestingChange?: (working: boolean) => void;
}
interface ConversationData extends AiConversation {
@@ -51,6 +73,18 @@ interface ConversationData extends AiConversation {
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();
@@ -91,7 +125,7 @@ export const aiBubbleRoles: BubbleListProps['role'] = {
assistant: { placement: 'start', variant: 'borderless' },
};
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const [loadingList, setLoadingList] = useState(false);
@@ -99,9 +133,20 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
const [input, setInput] = useState('');
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 [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,
@@ -112,6 +157,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
setConversation,
setConversations,
} = useXConversations({});
const activeConversationKeyRef = useRef(activeConversationKey);
const activeConversation = useMemo(
() => conversations.find((item) => item.key === activeConversationKey) as ConversationData | undefined,
@@ -119,29 +165,50 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
);
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);
const current = activeConversationKey;
const current = activeConversationKeyRef.current;
setActiveConversationKey(
current && items.some((item) => item.key === current) ? current : (items[0]?.key ?? ''),
);
}, [activeConversationKey, setActiveConversationKey, setConversations]);
}, [setActiveConversationKey, setConversations]);
const provider = useMemo(
() =>
activeId
? new GongxueAiChatProvider(conversationStreamUrl(activeId), () => {
void refreshConversations();
})
: undefined,
[activeId, refreshConversations],
const markConversationRunning = useCallback((conversationId: number) => {
setConversationStatus((current) => ({ ...current, [conversationId]: 'running' }));
}, []);
const markConversationFinished = useCallback(
(conversationId: number, result?: { ok: boolean; aborted?: boolean }) => {
requestAbortRef.current.delete(conversationId);
setConversationStatus((current) => ({
...current,
[conversationId]: result?.ok ? 'done' : result?.aborted ? 'stopped' : 'error',
}));
},
[],
);
const { messages, onRequest, onReload, isRequesting, abort, setMessage } = useXChat<
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,
@@ -164,14 +231,68 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
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;
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 = [];
@@ -182,16 +303,15 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
}, []);
useEffect(() => {
if (!open) return;
if (!open || loadedRef.current) return;
let cancelled = false;
setLoadingList(true);
Promise.all([aiChatApi.listSkills(), aiChatApi.listConversations()])
.then(async ([skillItems, conversationItems]) => {
.then(([skillItems, conversationItems]) => {
if (cancelled) return;
loadedRef.current = true;
setSkills(skillItems);
let next = sortConversations(conversationItems);
if (!next.length) next = [await aiChatApi.createConversation()];
const data = next.map(toConversationData);
const data = sortConversations(conversationItems).map(toConversationData);
setConversations(data);
setActiveConversationKey(data[0]?.key ?? '');
})
@@ -207,19 +327,20 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
if (isMobile) setSidebarOpen(false);
}, [activeConversationKey, discardPendingAttachments, isMobile]);
useEffect(() => () => stopRequest(), [stopRequest]);
useEffect(
() => () => {
for (const abort of requestAbortRef.current.values()) abort();
requestAbortRef.current.clear();
providersRef.current.clear();
},
[],
);
const createConversation = useCallback(async () => {
try {
stopRequest();
const created = toConversationData(await aiChatApi.createConversation());
addConversation(created, 'prepend');
setActiveConversationKey(created.key);
if (isMobile) setSidebarOpen(false);
} catch {
message.error('新建会话失败');
}
}, [addConversation, isMobile, setActiveConversationKey, stopRequest]);
/** 新建对话Codex 风格):先进入草稿态,发送第一条消息时才创建 session */
const startNewConversation = useCallback(() => {
setActiveConversationKey('');
if (isMobile) setSidebarOpen(false);
}, [isMobile, setActiveConversationKey]);
const renameConversation = useCallback(
(conversation: ConversationData) => {
@@ -243,6 +364,23 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
[setConversation],
);
/** 删除单个会话时中止请求并清理会话运行时状态 */
const removeConversationEntry = useCallback(
(conversation: ConversationData) => {
const abortRequest = requestAbortRef.current.get(conversation.id);
if (abortRequest) abortRequest();
else if (conversation.id === activeId) stopRequest();
requestAbortRef.current.delete(conversation.id);
providersRef.current.delete(conversation.id);
setConversationStatus((current) => {
const next = { ...current };
delete next[conversation.id];
return next;
});
},
[activeId, setConversationStatus, stopRequest],
);
const deleteConversation = useCallback(
(conversation: ConversationData) => {
Modal.confirm({
@@ -252,23 +390,113 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (conversation.id === activeId) stopRequest();
await aiChatApi.deleteConversation(conversation.id);
removeConversationEntry(conversation);
removeConversation(conversation.key);
const remaining = conversations.filter((item) => item.key !== conversation.key);
if (!remaining.length) {
const created = toConversationData(await aiChatApi.createConversation());
addConversation(created, 'prepend');
setActiveConversationKey(created.key);
setActiveConversationKey('');
} else if (conversation.id === activeId) {
setActiveConversationKey(remaining[0].key);
}
},
});
},
[activeId, addConversation, conversations, removeConversation, setActiveConversationKey, stopRequest],
[
activeId,
conversations,
removeConversation,
removeConversationEntry,
setActiveConversationKey,
],
);
const enterSelectionMode = useCallback(() => {
setSelectedKeys([]);
setSelectionMode(true);
}, []);
const exitSelectionMode = useCallback(() => {
setSelectedKeys([]);
setSelectionMode(false);
}, []);
const selectAllConversations = useCallback(() => {
setSelectedKeys(conversations.map((item) => item.key));
}, [conversations]);
const invertConversationSelection = useCallback(() => {
setSelectedKeys((current) => {
const selected = new Set(current);
return conversations.map((item) => item.key).filter((key) => !selected.has(key));
});
}, [conversations]);
const toggleConversationSelection = useCallback((key: string) => {
setSelectedKeys((current) =>
current.includes(key) ? current.filter((item) => item !== key) : [...current, key],
);
}, []);
const deleteSelectedConversations = useCallback(() => {
const selected = conversations.filter((item) =>
selectedKeys.includes(item.key),
) as ConversationData[];
if (!selected.length) return;
Modal.confirm({
title: `删除选中的 ${selected.length} 个会话`,
content: '选中的会话及全部历史消息将被永久删除,此操作不可恢复。',
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (selected.length === conversations.length) {
for (const abort of requestAbortRef.current.values()) abort();
requestAbortRef.current.clear();
providersRef.current.clear();
setConversationStatus({});
await aiChatApi.deleteAllConversations();
setConversations([]);
setActiveConversationKey('');
} else {
for (const item of selected) removeConversationEntry(item);
const deletedKeys: string[] = [];
const failedTitles: string[] = [];
await Promise.all(
selected.map(async (item) => {
try {
await aiChatApi.deleteConversation(item.id);
removeConversation(item.key);
deletedKeys.push(item.key);
} catch {
failedTitles.push(item.title);
}
}),
);
const deleted = new Set(deletedKeys);
const remaining = conversations.filter((item) => !deleted.has(item.key));
setConversations(remaining);
if (!remaining.length) {
setActiveConversationKey('');
} else if (activeId != null && !remaining.some((item) => item.id === activeId)) {
setActiveConversationKey(remaining[0].key);
}
if (failedTitles.length) message.error(`删除失败:${failedTitles.join('、')}`);
}
setSelectedKeys([]);
setSelectionMode(false);
},
});
}, [
activeId,
conversations,
removeConversation,
removeConversationEntry,
selectedKeys,
setActiveConversationKey,
setConversations,
]);
const conversationMenu = useCallback(
(item: ConversationItemType): MenuProps => ({
items: [
@@ -303,33 +531,157 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
const submit = useCallback(
(value: string) => {
const text = value.trim();
if (!text || !activeId || isRequesting) return;
if (!text || isRequesting) return;
const submittedAttachments = attachmentsRef.current;
attachmentsRef.current = [];
onRequest({
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,
});
setInput('');
setAttachments([]);
}, [activeConversation?.lockedSkillKey, activeId, isRequesting, onRequest]);
};
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>) => {
if (!activeId || typeof messageInfo.message.id !== 'number') return;
onReload(messageInfo.id, {
message: '',
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(),
regenerateMessageId: messageInfo.message.id,
reloadMessage: messageInfo.message,
reasoningEffort: deepThinking ? 'high' : null,
formSubmission: { formId: form.id, values, formTitle: form.title },
});
},
[activeConversation?.lockedSkillKey, activeId, onReload],
[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(
@@ -405,10 +757,57 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
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}
/>
),
})),
[messages, reloadMessage, updateFeedback],
[confirmReviewGroup, confirmReviewStep, messages, reloadMessage, submitForm, submitReview, updateFeedback],
);
const conversationItems = useMemo<ConversationItemType[]>(
() =>
conversations.map((item) => {
const status = conversationStatus[item.id];
let statusIndicator: React.ReactNode = null;
if (status === 'running') {
statusIndicator = (
<LoadingOutlined
spin
className="ai-chat-conversation-loading"
aria-label="生成中"
role="status"
/>
);
} else if (status === 'error' || status === 'stopped') {
statusIndicator = (
<span
className={`ai-chat-conversation-state is-${status}`}
aria-label={conversationStatusMeta(status).label}
>
<i />
{conversationStatusMeta(status).label}
</span>
);
}
const label = (
<span className="ai-chat-conversation-label">
{selectionMode && (
<Checkbox
checked={selectedKeys.includes(item.key)}
className="ai-chat-conversation-check"
aria-label={`选择 ${item.title}`}
/>
)}
<span className="ai-chat-conversation-label__title">{item.title}</span>
{statusIndicator}
</span>
);
return { ...item, label };
}),
[conversationStatus, conversations, selectedKeys, selectionMode],
);
const skillMenu: MenuProps = {
@@ -423,13 +822,10 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
return (
<Drawer
title={<span className="ai-chat-title"><RobotOutlined /> AI </span>}
title={<span className="ai-chat-title"><RobotOutlined /> AI </span>}
open={open}
onClose={() => {
stopRequest();
discardPendingAttachments();
onClose();
}}
closeIcon={<ArrowRightOutlined title="收起到后台继续运行" />}
onClose={onClose}
width={isMobile ? '100%' : 'min(1040px, 92vw)'}
destroyOnHidden={false}
className="ai-chat-drawer"
@@ -438,16 +834,55 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
<div className="ai-chat-layout">
<aside className={`ai-chat-sidebar${sidebarOpen ? ' is-open' : ''}`}>
<Conversations
items={conversations as ConversationItemType[]}
items={conversationItems}
activeKey={activeConversationKey}
onActiveChange={(key) => {
stopRequest();
setActiveConversationKey(key);
if (selectionMode) toggleConversationSelection(key);
else setActiveConversationKey(key);
}}
menu={conversationMenu}
creation={{ label: '新对话', icon: <PlusOutlined />, onClick: createConversation }}
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>
<main className="ai-chat-main">
@@ -473,8 +908,8 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
<Welcome
variant="borderless"
icon={<RobotOutlined />}
title="你好,我是学 AI 助手"
description={lockedSkill?.description || '我会在你的权限范围内查询学生、考勤、宿舍、账单和经营数据。'}
title="你好,我是学 AI 助手"
description={lockedSkill?.description || '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'}
/>
<Prompts
title="你可以这样问"
@@ -505,31 +940,44 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
: undefined
}
header={
uploadItems.length ? (
<Attachments
items={uploadItems}
customRequest={customUpload}
onRemove={removeAttachment}
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
multiple
/>
) : false
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>
)
}
prefix={
<Attachments
items={[]}
customRequest={customUpload}
onRemove={removeAttachment}
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
multiple
placeholder={{ title: '添加附件', description: '图片、PDF、Word、Excel单个不超过 10MB' }}
>
<Button type="text" size="small"></Button>
</Attachments>
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>
}
/>
<Typography.Text type="secondary" className="ai-chat-disclaimer">
AI
AI
</Typography.Text>
</div>
</main>

View File

@@ -10,16 +10,33 @@ import {
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Actions, CodeHighlighter, FileCard, Think, ThoughtChain } from '@ant-design/x';
import {
Actions,
CodeHighlighter,
FileCard,
Mermaid,
Sources,
Think,
ThoughtChain,
} from '@ant-design/x';
import type { ThoughtChainItemType } from '@ant-design/x';
import XMarkdown from '@ant-design/x-markdown';
import type { ComponentProps } from '@ant-design/x-markdown';
import { Alert, Flex, Space, Typography } from 'antd';
import { useUserStore } from '../../store/user/userStore';
import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview';
import type {
AiAttachment,
AiChatMessage,
AiChatMessageStatus,
AiChartSchema,
AiFormSchema,
AiMessageFeedback,
AiReviewSection,
AiReviewSchema,
AiReviewSectionType,
AiToolRun,
} from './types';
@@ -32,12 +49,24 @@ const toolLabels: Record<string, string> = {
get_room_occupancy_summary: '统计入住',
search_bills: '查询账单',
get_dashboard_stats: '读取经营概览',
render_form: '生成表单',
render_review: '生成导入预览',
render_chart: '生成图表',
create_student: '创建学生',
search_exams: '查询考试',
search_schedules: '查询课表',
search_deposits: '查询押金',
search_expenses: '查询费用',
search_classrooms: '查询教室',
search_classroom_rentals: '查询教室租用',
get_sync_status: '查询同步状态',
};
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>;
},
};
@@ -57,7 +86,7 @@ function attachmentIcon(attachment: AiAttachment) {
}
async function openAttachment(attachment: AiAttachment): Promise<void> {
const token = localStorage.getItem('token');
const token = useUserStore.getState().token;
const response = await fetch(attachment.url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
@@ -67,6 +96,18 @@ async function openAttachment(attachment: AiAttachment): Promise<void> {
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
async function openSourceUrl(item: { url?: string }): Promise<void> {
if (!item.url) return;
const token = useUserStore.getState().token;
const response = await fetch(item.url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!response.ok) throw new Error('来源打开失败');
const objectUrl = URL.createObjectURL(await response.blob());
window.open(objectUrl, '_blank', 'noopener,noreferrer');
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
function ToolChain({ tools }: { tools: AiToolRun[] }) {
const items = useMemo<ThoughtChainItemType[]>(
() =>
@@ -99,6 +140,18 @@ export interface AiMessageContentProps {
status?: AiChatMessageStatus;
onReload?: () => void;
onFeedback?: (feedback: AiMessageFeedback) => void;
onSubmitForm?: (form: AiFormSchema, values: Record<string, unknown>) => void;
onSubmitReview?: (reviewId: string, reviewTitle?: string) => void;
onConfirmReviewStep?: (
messageId: number | undefined,
reviewId: string,
sectionKey: AiReviewSection['key'],
) => AiReviewSchema | Promise<AiReviewSchema> | void;
onConfirmReviewGroup?: (
messageId: number | undefined,
reviewId: string,
type: AiReviewSectionType,
) => AiReviewSchema | Promise<AiReviewSchema> | void;
}
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
@@ -106,8 +159,28 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
status,
onReload,
onFeedback,
onSubmitForm,
onSubmitReview,
onConfirmReviewStep,
onConfirmReviewGroup,
}) => {
const streaming = status === 'loading' || status === 'updating';
const formSubmission = message.metadata?.a2uiSubmit;
const reviewSubmission = message.metadata?.a2uiReviewSubmit;
const sourceMeta = message.metadata?.a2uiSources;
const sourceItems = Array.isArray(sourceMeta)
? sourceMeta
.filter(
(item): item is { title: string; url?: string; description?: string } =>
Boolean(item) && typeof (item as { title?: unknown }).title === 'string',
)
.map((item, index) => ({
key: `source-${index}`,
title: item.title,
...(item.url ? { url: item.url } : {}),
...(item.description ? { description: item.description } : {}),
}))
: [];
const attachmentCards = message.attachments.map((attachment) => (
<FileCard
key={attachment.id}
@@ -120,6 +193,28 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
));
if (message.role === 'user') {
if (reviewSubmission && typeof reviewSubmission === 'object') {
const reviewTitle =
typeof (reviewSubmission as Record<string, unknown>).reviewTitle === 'string'
? String((reviewSubmission as Record<string, unknown>).reviewTitle)
: '批量导入';
return (
<Space direction="vertical" size={8} className="ai-chat-user-content">
<Alert type="success" showIcon message={`已确认导入《${reviewTitle}`} />
</Space>
);
}
if (formSubmission && typeof formSubmission === 'object') {
const formTitle =
typeof (formSubmission as Record<string, unknown>).formTitle === 'string'
? String((formSubmission as Record<string, unknown>).formTitle)
: '表单';
return (
<Space direction="vertical" size={8} className="ai-chat-user-content">
<Alert type="info" showIcon message={`已提交《${formTitle}`} />
</Space>
);
}
return (
<Space direction="vertical" size={8} className="ai-chat-user-content">
{attachmentCards.length > 0 && <Flex wrap gap={8}>{attachmentCards}</Flex>}
@@ -158,6 +253,19 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
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>
)}
{message.retrying && (
<Alert
type="warning"
showIcon
message={`AI 服务繁忙,正在自动重试(第 ${message.retrying.attempt} / ${message.retrying.maxRetries} 次)...`}
description={message.retrying.reason ? `原因:${message.retrying.reason}` : undefined}
/>
)}
{message.reasoningContent && (
<Think title={streaming ? '正在思考' : '思考过程'} loading={streaming} defaultExpanded={false}>
<XMarkdown
@@ -187,6 +295,35 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
}}
/>
)}
{sourceItems.length > 0 && (
<Sources
items={sourceItems}
title="引用来源"
onClick={(item) => void openSourceUrl(item as { url?: string })}
/>
)}
{(message.forms ?? []).map((form) => (
<DynamicForm
key={form.id}
form={form}
disabled={streaming}
onSubmit={(values) => onSubmitForm?.(form, values)}
/>
))}
{(message.reviews ?? []).map((review: AiReviewSchema) => (
<DynamicReview
key={review.id}
review={review}
messageId={typeof message.id === 'number' ? message.id : undefined}
disabled={streaming}
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
onConfirmStep={onConfirmReviewStep}
onConfirmGroup={onConfirmReviewGroup}
/>
))}
{(message.charts ?? []).map((chart: AiChartSchema) => (
<DynamicChart key={chart.id} chart={chart} />
))}
{message.error && <Alert type="error" showIcon message={message.error} />}
{message.cancelled && <Typography.Text type="secondary"></Typography.Text>}
{!streaming && message.content && <Actions items={actionItems} fadeIn />}

View File

@@ -0,0 +1,295 @@
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 { DownloadOutlined } from '@ant-design/icons';
import type { EChartsType } from 'echarts/core';
import ReactECharts, { type EChartsOption } from '../../components/ECharts';
import type { AiChartSchema } from './types';
const CHART_CATALOG_ID = 'gongxue-chart-catalog';
registerCatalog({
catalogId: CHART_CATALOG_ID,
components: {
ChartPreview: {
type: 'object',
properties: {
chart: { type: 'object' },
},
},
},
});
function surfaceId(chartId: string): string {
return `chart-${chartId}`;
}
function numberValue(value: unknown): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
const CHART_TYPE_LABELS: Record<string, string> = {
line: '折线图',
bar: '柱状图',
pie: '饼图',
area: '面积图',
scatter: '散点图',
radar: '雷达图',
gauge: '仪表盘',
funnel: '漏斗图',
};
function buildOption(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})`;
},
},
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}' },
},
],
};
}
const categoryField = columns[0]?.key ?? '';
const categories = chart.rows.map((row) => String(row[categoryField] ?? ''));
const series = columns.slice(1).map((column) => ({
name: column.title,
type: chart.chartType === 'area' ? 'line' : chart.chartType,
smooth: chart.chartType === 'line',
...(chart.chartType === 'area' ? { areaStyle: { opacity: 0.18 } } : {}),
data: chart.rows.map((row) => numberValue(row[column.key])),
}));
return {
tooltip: { trigger: 'axis' },
legend: { bottom: 0, type: 'scroll' },
grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true },
xAxis: {
type: 'category',
data: categories,
axisLabel: { interval: 0, rotate: categories.length > 8 ? 30 : 0 },
},
yAxis: { type: 'value' },
series,
};
}
interface ChartPreviewProps {
chart?: AiChartSchema;
}
/**
* A2UI component registered for the `gongxue-chart-catalog` catalog.
* Receives the validated tabular chart data through data binding and
* renders an ECharts option built from it.
*/
const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
const [instance, setInstance] = useState<EChartsType | null>(null);
if (!chart) return null;
const downloadImage = () => {
if (!instance) return;
const url = instance.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: '#fff',
});
const link = document.createElement('a');
link.href = url;
link.download = `${chart.title || '图表'}.png`;
document.body.appendChild(link);
link.click();
link.remove();
};
return (
<div className="ai-chat-chart-card">
<div className="ai-chat-chart-card__header">
<Typography.Text strong>{chart.title}</Typography.Text>
<span className="ai-chat-chart-card__header-actions">
<Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>
<Tooltip title="下载图片">
<Button
type="text"
size="small"
className="ai-chat-chart-card__download"
aria-label="下载图表图片"
icon={<DownloadOutlined />}
onClick={downloadImage}
disabled={!instance}
/>
</Tooltip>
</span>
</div>
<ReactECharts
option={option}
style={{ width: '100%', height: 260 }}
onReady={setInstance}
/>
</div>
);
};
export interface DynamicChartProps {
chart: AiChartSchema;
}
/**
* Chart card rendered through the official @ant-design/x-card renderer.
* Display-only: no submit endpoint, the schema lives in message metadata
* so history replays identically.
*/
export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
useEffect(() => {
const sid = surfaceId(chart.id);
if (idRef.current !== sid) {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID },
});
}
cmds.push({
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/chart',
value: chart,
},
});
cmds.push({
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'ChartPreview',
chart: { path: '/chart' },
},
],
},
});
setCommands([...cmds]);
}, [chart]);
return (
<div className="ai-chat-chart">
<XCard.Box components={{ ChartPreview }} commands={commands}>
<XCard.Card id={surfaceId(chart.id)} />
</XCard.Box>
</div>
);
};
export default DynamicChart;

View File

@@ -0,0 +1,239 @@
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 dayjs from 'dayjs';
import type { AiFormField, AiFormSchema } from './types';
const FORM_CATALOG_ID = 'gongxue-form-catalog';
registerCatalog({
catalogId: FORM_CATALOG_ID,
components: {
FormPreview: {
type: 'object',
properties: {
form: { type: 'object' },
disabled: { type: 'boolean' },
},
},
},
});
function surfaceId(formId: string): string {
return `form-${formId}`;
}
function initialValue(field: AiFormField): unknown {
if (field.type === 'date' && typeof field.defaultValue === 'string') {
const parsed = dayjs(field.defaultValue);
return parsed.isValid() ? parsed : undefined;
}
return field.defaultValue;
}
function normalizeValues(
fields: AiFormField[],
raw: Record<string, unknown>,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const field of fields) {
const value = raw[field.name];
if (value === undefined || value === null || value === '') continue;
values[field.name] =
field.type === 'date' && dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : value;
}
return values;
}
interface FormPreviewProps {
form?: AiFormSchema;
disabled?: boolean;
onAction?: (name: string, context: Record<string, unknown>) => void;
}
/**
* A2UI component registered for the `gongxue-form-catalog` catalog.
* Receives the validated form schema through data binding and reports
* 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 initialValues = useMemo(
() => 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 handleFinish = (values: Record<string, unknown>) => {
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
};
return (
<Flex vertical gap={8}>
<Typography.Text strong>{form.title}</Typography.Text>
{form.description && (
<Typography.Text type="secondary" className="ai-chat-dynamic-form__desc">
{form.description}
</Typography.Text>
)}
{finished ? (
<Alert type="success" showIcon message="已提交AI 正在处理…" />
) : (
<Form
layout="vertical"
size="small"
initialValues={initialValues}
onFinish={(values) => void handleFinish(values as Record<string, unknown>)}
disabled={disabled || submitting}
requiredMark={false}
>
{form.fields.map((field) => (
<Form.Item
key={field.name}
name={field.name}
label={field.label}
rules={[
{
required: field.required,
message: field.required
? field.type === 'select' || field.type === 'date'
? `请选择${field.label}`
: `请输入${field.label}`
: undefined,
},
]}
>
{field.type === 'textarea' ? (
<Input.TextArea rows={3} placeholder={field.placeholder} />
) : field.type === 'number' ? (
<InputNumber
className="ai-chat-dynamic-form__number"
placeholder={field.placeholder}
/>
) : field.type === 'select' ? (
<Select
allowClear={!field.required}
placeholder={field.placeholder}
options={field.options}
/>
) : field.type === 'date' ? (
<DatePicker className="ai-chat-dynamic-form__date" placeholder={field.placeholder} />
) : (
<Input placeholder={field.placeholder} />
)}
</Form.Item>
))}
{runtime.error && (
<Alert
type="error"
showIcon
message={runtime.error}
className="ai-chat-dynamic-form__error"
/>
)}
<Button type="primary" htmlType="submit" loading={submitting} disabled={disabled}>
{form.submitLabel || '提交'}
</Button>
</Form>
)}
</Flex>
);
};
export interface DynamicFormProps {
form: AiFormSchema;
disabled?: boolean;
onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
}
/**
* A2UI form rendered through the official @ant-design/x-card renderer.
* The validated schema is bound into the surface data model; submit
* success/failure/loading transitions are pushed as incremental commands.
*/
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
useEffect(() => {
const sid = surfaceId(form.id);
if (idRef.current !== sid) {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
});
}
cmds.push({
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/form',
value: { ...form, submitting, submitted, error },
},
});
cmds.push({
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'FormPreview',
form: { path: '/form' },
disabled: Boolean(disabled),
},
],
},
});
setCommands([...cmds]);
}, [disabled, error, form, submitted, submitting]);
const handleSubmit = async (values: Record<string, unknown>) => {
if (submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(values);
setSubmitted(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
};
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'form:submit') return;
const values =
payload.context?.values && typeof payload.context.values === 'object'
? (payload.context.values as Record<string, unknown>)
: {};
void handleSubmit(values);
};
return (
<div className="ai-chat-dynamic-form">
<XCard.Box components={{ FormPreview }} commands={commands} onAction={handleAction}>
<XCard.Card id={surfaceId(form.id)} />
</XCard.Box>
</div>
);
};
export default DynamicForm;

View File

@@ -0,0 +1,699 @@
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';
const REVIEW_CATALOG_ID = 'gongxue-review-catalog';
registerCatalog({
catalogId: REVIEW_CATALOG_ID,
components: {
ReviewPreview: {
type: 'object',
properties: {
review: { type: 'object' },
disabled: { type: 'boolean' },
activeKey: { type: 'string' },
activeType: { type: 'string' },
submittingKey: { type: ['string', 'null'] },
submittingGroup: { type: 'boolean' },
error: { type: ['string', 'null'] },
},
},
},
});
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) {
return String((reason as { message?: unknown }).message ?? '确认失败,请稍后重试');
}
return '确认失败,请稍后重试';
}
function SectionTable({ section }: { section: AiReviewSection }) {
const columns: TableProps<AiReviewRow>['columns'] = section.columns.map((column) => ({
title: column.title,
dataIndex: column.key,
key: column.key,
ellipsis: true,
render: (value: unknown) =>
value === null || value === undefined || value === '' ? (
<Typography.Text type="secondary">-</Typography.Text>
) : (
String(value)
),
}));
return (
<Table<AiReviewRow>
size="small"
rowKey="__rowKey"
columns={columns}
dataSource={section.rows.map((row, index) => ({ ...row, __rowKey: `row-${index}` }))}
pagination={{ pageSize: 10, size: 'small', hideOnSinglePage: true }}
scroll={{ x: 'max-content' }}
/>
);
}
interface ReviewPreviewProps {
review?: AiReviewSchema;
disabled?: boolean;
onAction?: (name: string, context: Record<string, unknown>) => void;
}
const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onAction }) => {
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 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)
: presentTypes[0];
if (!activeType) return null;
const activeSection =
sections.find((section) => section.key === runtime.activeKey) ??
groupSections(sections, activeType)[0];
const activeStatus = activeSection ? sectionStatus(activeSection) : 'pending';
const dependency =
activeSection === undefined ? null : dependencyHint(sections, sectionType(activeSection));
const typeItems = presentTypes.map((type, index) => {
const items = groupSections(sections, type);
const status = groupStatus(sections, type, submittingKey, submittingGroup, activeType);
const stepStatus: 'finish' | 'error' | 'process' | 'wait' =
status === 'submitted'
? 'finish'
: status === 'failed'
? 'error'
: status === 'importing' || type === activeType
? 'process'
: 'wait';
return {
key: type,
title: `${SECTION_TYPE_LABELS[type]}${items.reduce((sum, item) => sum + sectionCount(item), 0)}`,
content: GROUP_STATUS_LABELS[status],
status: stepStatus,
index,
};
});
const group = groupSections(sections, activeType);
const typeTotal = group.reduce((sum, section) => sum + sectionCount(section), 0);
const groupDep = dependencyHint(sections, activeType);
const groupReady =
!submitted &&
!expired &&
!disabled &&
!submitting &&
!submittingKey &&
!submittingGroup &&
group.length > 0 &&
!group.every((section) => sectionStatus(section) === 'submitted') &&
!groupDep;
const anyRunning = submitting || Boolean(submittingKey) || submittingGroup;
const allIssues = sections.flatMap((section) => section.issues);
const allRows = sections.reduce((sum, section) => sum + sectionCount(section), 0);
return (
<div className="ai-chat-review-card">
<Flex justify="space-between" align="center" wrap gap={8}>
<Typography.Text strong className="ai-chat-review-card__title">
{review.title}
</Typography.Text>
{submitted ? (
<Tag color="success"></Tag>
) : expired ? (
<Tag></Tag>
) : anyRunning ? (
<Tag color="processing"></Tag>
) : (
<Tag color="gold"></Tag>
)}
</Flex>
{review.summary && (
<Typography.Paragraph type="secondary" className="ai-chat-review-card__summary">
{review.summary}
</Typography.Paragraph>
)}
{expired && (
<Alert
type="warning"
showIcon
message="此导入预览已被新的预览替代,已失效"
description="如需导入,请使用最新的预览卡。"
/>
)}
<Steps
size="small"
current={Math.max(0, typeItems.findIndex((item) => item.key === activeType))}
items={typeItems.map((item) => ({
key: item.key,
title: item.title,
content: item.content,
status: item.status,
}))}
onChange={(index) => {
const type = typeItems[index]?.key;
if (type) onAction?.('review:selectType', { type });
}}
/>
{activeType && (
<Flex vertical gap={8} className="ai-chat-review-card__group">
<Flex justify="space-between" align="center" wrap gap={8}>
<Flex vertical gap={2}>
<Typography.Text strong>
{SECTION_TYPE_LABELS[activeType]} · {group.length} / {typeTotal}
</Typography.Text>
<Typography.Text type="secondary">
{GROUP_STATUS_LABELS[
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
]}
</Typography.Text>
</Flex>
{groupDep && (
<Alert
type="warning"
showIcon
message={
groupDep.step === -1
? `${groupDep.title}」分表尚未生成或导入,请先确认前置步骤`
: `请先确认第 ${groupDep.step + 1} 步「${groupDep.title}`
}
/>
)}
{!submitted && !expired && group.length > 0 && (
<Popconfirm
title={`确认导入本组 ${group.length} 张表?`}
description={`本组共 ${typeTotal} 行,确认后将按顺序逐表导入。`}
okText="确认导入"
cancelText="取消"
disabled={!groupReady}
onConfirm={() =>
onAction?.('review:confirmGroup', {
reviewId: review.id,
type: activeType,
})
}
>
<Button
type="primary"
loading={submittingGroup}
disabled={!groupReady}
>
{groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) ===
'submitted'
? '已导入'
: `确认本组 ${group.length} 张表`}
</Button>
</Popconfirm>
)}
</Flex>
<Flex vertical gap={8} className="ai-chat-review-card__sheets">
{group.map((section, index) => {
const status = sectionStatus(section);
const dep = dependencyHint(sections, sectionType(section));
const canConfirm =
!submitted &&
!expired &&
!disabled &&
!anyRunning &&
status !== 'submitted' &&
status !== 'skipped' &&
!dep;
return (
<Flex
key={section.key}
justify="space-between"
align="center"
wrap
gap={8}
className="ai-chat-review-card__sheet"
onClick={() =>
onAction?.('review:selectStep', { sectionKey: section.key })
}
>
<Flex vertical gap={2} style={{ minWidth: 160 }}>
<Typography.Text>
{index + 1}. {section.title}
{section.sheet ? (
<Typography.Text type="secondary">{section.sheet}</Typography.Text>
) : null}
</Typography.Text>
<Typography.Text type="secondary">
{sectionCount(section)} · {SECTION_STATUS_LABELS[status]}
</Typography.Text>
</Flex>
<Button
size="small"
loading={submittingKey === section.key}
disabled={!canConfirm}
onClick={(event) => {
event.stopPropagation();
onAction?.('review:confirmStep', {
reviewId: review.id,
sectionKey: section.key,
});
}}
>
{status === 'failed'
? '重试导入本步'
: status === 'submitted'
? '已导入'
: status === 'skipped'
? '已跳过'
: expired
? '已失效'
: '确认导入本步'}
</Button>
</Flex>
);
})}
</Flex>
{activeSection && (
<Flex vertical gap={8} className="ai-chat-review-card__step">
{activeSection.issues.length > 0 && (
<Alert
type="warning"
showIcon
message={`${activeSection.title}${activeSection.issues.length} 条待处理`}
description={
<ul className="ai-chat-review__issues">
{activeSection.issues.slice(0, 20).map((issue, issueIndex) => (
<li key={issueIndex}>{issue}</li>
))}
</ul>
}
/>
)}
<SectionTable section={activeSection} />
{dependency && (
<Alert
type="warning"
showIcon
message={
dependency.step === -1
? `${dependency.title}」分表尚未生成或导入,请先确认前置步骤`
: `请先确认第 ${dependency.step + 1} 步「${dependency.title}`
}
/>
)}
{activeStatus === 'failed' && (
<Alert type="error" showIcon message="本步导入失败,可重试" />
)}
{activeSection.resultSummary && activeStatus === 'submitted' && (
<Typography.Text type="secondary" className="ai-chat-review-card__step-result">
{sectionResultText(activeSection)}
</Typography.Text>
)}
</Flex>
)}
</Flex>
)}
<Flex justify="space-between" align="center" wrap gap={8} className="ai-chat-review-card__footer">
<Typography.Text type="secondary">
{allRows} {allIssues.length}
</Typography.Text>
{!submitted && !expired && (
<Popconfirm
title={`确认导入全部 ${sections.length} 张表?`}
description={`全部共 ${allRows} 行,将按类型与依赖顺序逐表导入。`}
okText="确认导入"
cancelText="取消"
disabled={submitting || anyRunning || disabled}
onConfirm={() => onAction?.('review:submit', { reviewId: review.id })}
>
<Button
type="primary"
loading={submitting}
disabled={disabled || anyRunning}
>
</Button>
</Popconfirm>
)}
</Flex>
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />}
{runtime.error && (
<Alert
type="error"
showIcon
message={runtime.error}
className="ai-chat-review-card__step-error"
/>
)}
</div>
);
};
export interface DynamicReviewProps {
review: AiReviewSchema;
disabled?: boolean;
messageId?: number;
onSubmit: (reviewId: string) => void | Promise<void>;
onConfirmStep?: (
messageId: number | undefined,
reviewId: string,
sectionKey: string,
) => AiReviewSchema | Promise<AiReviewSchema> | void;
onConfirmGroup?: (
messageId: number | undefined,
reviewId: string,
type: AiReviewSectionType,
) => AiReviewSchema | Promise<AiReviewSchema> | void;
}
/**
* Batch-import review card rendered through the official A2UI renderer
* (@ant-design/x-card). Sections are grouped by business type; each sheet is
* confirmed independently, the whole type group can be confirmed together, or
* everything can be confirmed in one flow.
*/
export const DynamicReview: React.FC<DynamicReviewProps> = ({
review,
disabled,
messageId,
onSubmit,
onConfirmStep,
onConfirmGroup,
}) => {
const [submitting, setSubmitting] = useState(false);
const [submittingKey, setSubmittingKey] = useState<string | null>(null);
const [submittingGroup, setSubmittingGroup] = useState(false);
const [activeKey, setActiveKey] = useState<string | undefined>(undefined);
const [activeType, setActiveType] = useState<AiReviewSectionType | undefined>(undefined);
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
const [error, setError] = useState<string | null>(null);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
useEffect(() => {
setLocalReview(review);
const types = SECTION_ORDER.filter((type) =>
review.sections.some((section) => sectionType(section) === type),
);
const preferredType =
activeType && types.includes(activeType) ? activeType : types[0];
setActiveType(preferredType);
setActiveKey((current) =>
current &&
review.sections.some(
(section) => section.key === current && sectionType(section) === preferredType,
)
? current
: review.sections.find((section) => sectionType(section) === preferredType)?.key,
);
}, [activeType, review]);
useEffect(() => {
const sid = surfaceId(localReview.id);
if (idRef.current !== sid) {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID },
});
}
cmds.push({
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/review',
value: {
...localReview,
submitting,
activeKey,
activeType,
submittingKey,
submittingGroup,
error,
},
},
});
cmds.push({
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'ReviewPreview',
review: { path: '/review' },
disabled: Boolean(disabled),
},
],
},
});
setCommands([...cmds]);
}, [activeKey, activeType, disabled, error, localReview, submitting, submittingGroup, submittingKey]);
const handleSubmit = async (reviewId: string) => {
if (submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(reviewId);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
};
const handleConfirmStep = async (reviewId: string, sectionKey: string) => {
if (submittingKey) return;
setSubmittingKey(sectionKey);
setError(null);
try {
const updated = await onConfirmStep?.(messageId, reviewId, sectionKey);
if (updated) setLocalReview(updated);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmittingKey(null);
}
};
const handleConfirmGroup = async (reviewId: string, type: AiReviewSectionType) => {
if (submittingGroup) return;
setSubmittingGroup(true);
setError(null);
try {
const updated = await onConfirmGroup?.(messageId, reviewId, type);
if (updated) setLocalReview(updated);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmittingGroup(false);
}
};
const handleAction = (payload: ActionPayload) => {
const context = payload.context ?? {};
if (payload.name === 'review:submit') {
const reviewId =
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
void handleSubmit(reviewId);
return;
}
if (payload.name === 'review:selectType') {
const type = context.type as AiReviewSectionType | undefined;
if (type && SECTION_ORDER.includes(type)) {
setActiveType(type);
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,
);
setActiveKey(context.sectionKey);
if (section) setActiveType(sectionType(section));
}
return;
}
if (payload.name === 'review:confirmStep') {
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 type = context.type as AiReviewSectionType | undefined;
if (type && SECTION_ORDER.includes(type)) {
void handleConfirmGroup(reviewId, type);
}
}
};
return (
<div className="ai-chat-review">
<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" />}
</div>
);
};
export default DynamicReview;

View File

@@ -37,4 +37,38 @@ describe('AI chat API adapter', () => {
const page = await aiChatApi.listMessages(3);
expect(page.items.map((item) => item.id)).toEqual([1, 101]);
});
it('confirmReviewStep posts to the per-section confirm endpoint', async () => {
const updated = {
id: 'review-1',
title: '批量导入',
status: 'pending',
sections: [
{ key: 'students', type: 'students', title: '学生', status: 'submitted' },
],
};
vi.spyOn(api, 'post').mockResolvedValue({ success: true, data: updated });
await expect(aiChatApi.confirmReviewStep('review-1', 'students')).resolves.toEqual(updated);
expect(api.post).toHaveBeenCalledWith(
'/ai/chat/reviews/review-1/steps/students/confirm',
);
});
it('confirmReviewGroup posts to the per-type confirm endpoint', async () => {
const updated = {
id: 'review-1',
title: '批量导入',
status: 'pending',
sections: [
{ key: 'checkins_a', type: 'checkins', title: '入住A', status: 'submitted' },
],
};
vi.spyOn(api, 'post').mockResolvedValue({ success: true, data: updated });
await expect(aiChatApi.confirmReviewGroup('review-1', 'checkins')).resolves.toEqual(updated);
expect(api.post).toHaveBeenCalledWith(
'/ai/chat/reviews/review-1/types/checkins/confirm',
);
});
});

View File

@@ -5,6 +5,9 @@ import type {
AiConversation,
AiMessageFeedback,
AiMessagePage,
AiReviewSchema,
AiReviewSection,
AiReviewSectionType,
AiSkill,
} from './types';
@@ -21,6 +24,8 @@ export const aiChatApi = {
input: { title?: string; lockedSkillKey?: string | null },
) => (await api.patch<AiApiResponse<AiConversation>>(`${basePath}/${id}`, input)).data,
deleteConversation: (id: number) => api.delete<void>(`${basePath}/${id}`),
deleteAllConversations: async () =>
(await api.delete<{ success: boolean; data: { deleted: number } }>(basePath)).data,
uploadAttachment: async (file: File): Promise<AiAttachment> => {
const form = new FormData();
form.append('file', file);
@@ -43,6 +48,24 @@ export const aiChatApi = {
{ feedback, reason },
)
).data,
confirmReviewStep: async (
reviewId: string,
sectionKey: AiReviewSection['key'],
): Promise<AiReviewSchema> =>
(
await api.post<AiApiResponse<AiReviewSchema>>(
`/ai/chat/reviews/${reviewId}/steps/${sectionKey}/confirm`,
)
).data,
confirmReviewGroup: async (
reviewId: string,
type: AiReviewSectionType,
): Promise<AiReviewSchema> =>
(
await api.post<AiApiResponse<AiReviewSchema>>(
`/ai/chat/reviews/${reviewId}/types/${type}/confirm`,
)
).data,
listMessages: async (id: number): Promise<AiMessagePage> => {
const first = (
await api.get<AiApiResponse<AiMessagePage>>(`${basePath}/${id}/messages`, {

View File

@@ -2,8 +2,12 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Bubble } from '@ant-design/x';
import { afterEach, describe, expect, it } from 'vitest';
import { aiBubbleRoles } from './AiChatDrawer';
import type { AiChatMessage } from './types';
import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer';
import { AiMessageContent } from './AiMessageContent';
import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview';
import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types';
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
@@ -16,6 +20,13 @@ afterEach(async () => {
});
describe('AI chat bubble rendering', () => {
it('maps conversation run statuses to list labels', () => {
expect(conversationStatusMeta('running')).toEqual({ label: '生成中', color: 'processing' });
expect(conversationStatusMeta('done')).toEqual({ label: '已完成', color: 'success' });
expect(conversationStatusMeta('error')).toEqual({ label: '失败', color: 'error' });
expect(conversationStatusMeta('stopped')).toEqual({ label: '已停止', color: 'default' });
});
it('renders a structured user message instead of passing the object to React', async () => {
const message: AiChatMessage = {
role: 'user',
@@ -32,11 +43,433 @@ describe('AI chat bubble rendering', () => {
root?.render(
<Bubble.List
role={aiBubbleRoles}
items={[{ key: 'user-1', role: 'user', status: 'local', content: message }]}
items={[
{
key: 'user-1',
role: 'user',
status: 'local',
content: message,
contentRender: (content: AiChatMessage) => <div>{content.content}</div>,
},
]}
/>,
);
});
expect(container.textContent).toContain('查询今天的系统概览');
});
it('renders an A2UI form and submits normalized values', async () => {
let submitted: Record<string, unknown> | null = null;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<DynamicForm
form={{
id: 'form-1',
title: '新增学生',
submitLabel: '提交创建',
fields: [
{ name: 'name', label: '姓名', type: 'input', required: true },
{ name: 'studentNo', label: '学号', type: 'input' },
],
}}
onSubmit={(values) => {
submitted = values;
}}
/>,
);
});
expect(container.textContent).toContain('新增学生');
const input = container.querySelector('input#name') as HTMLInputElement | null;
expect(input).not.toBeNull();
if (input) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(input, '张三');
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const submitButton = container.querySelector('button[type="submit"]') as HTMLButtonElement | null;
expect(submitButton).not.toBeNull();
await act(async () => {
submitButton?.click();
});
expect(submitted).toEqual({ name: '张三' });
expect(container.textContent).toContain('已提交');
});
it('renders an A2UI review card and submits via the confirm button', async () => {
let submittedId: string | null = null;
const review: AiReviewSchema = {
id: 'review-1',
title: '开学导入',
summary: '来自报名 Excel',
status: 'pending',
sections: [
{
key: 'students',
type: 'students',
title: '学生',
kind: 'table',
columns: [
{ key: 'name', title: '姓名' },
{ key: 'phone', title: '手机号' },
],
rows: [
{ name: '张三', phone: '13800138000' },
{ name: '李四', phone: '13900139000' },
],
issues: [],
},
],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<DynamicReview
review={review}
onSubmit={(reviewId) => {
submittedId = reviewId;
}}
/>,
);
});
expect(container.textContent).toContain('开学导入');
expect(container.textContent).toContain('确认导入本步');
expect(container.textContent).toContain('全部确认并入库');
const button = Array.from(container.querySelectorAll('button')).find((item) =>
item.textContent?.includes('全部确认并入库'),
) as HTMLButtonElement | undefined;
expect(button).not.toBeNull();
await act(async () => {
button?.click();
});
const confirmButton = Array.from(document.body.querySelectorAll('button')).find(
(item) => item.textContent?.trim() === '确认导入',
) as HTMLButtonElement | undefined;
expect(confirmButton).toBeDefined();
await act(async () => {
confirmButton?.click();
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(submittedId).toBe('review-1');
});
it('renders grouped sheets and confirms one type group via Popconfirm', async () => {
let submittedGroup: { reviewId: string; type: string } | null = null;
const review: AiReviewSchema = {
id: 'review-2',
title: '入住分表',
status: 'pending',
sections: [
{
key: 'checkins_girls_4',
type: 'checkins',
title: '四人间女',
kind: 'table',
columns: [
{ key: 'name', title: '姓名' },
{ key: 'roomNumber', title: '宿舍号' },
],
rows: [{ name: '张三', roomNumber: '4-401' }],
issues: [],
},
{
key: 'checkins_boys_4',
type: 'checkins',
title: '四人间男',
kind: 'table',
columns: [
{ key: 'name', title: '姓名' },
{ key: 'roomNumber', title: '宿舍号' },
],
rows: [{ name: '李四', roomNumber: '4-402' }],
issues: [],
},
],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<DynamicReview
review={review}
onSubmit={() => undefined}
onConfirmGroup={(_, reviewId, type) => {
submittedGroup = { reviewId, type };
}}
/>,
);
});
expect(container.textContent).toContain('入住记录 · 共 2 张表');
expect(container.textContent).toContain('确认本组 2 张表');
const groupButton = Array.from(container.querySelectorAll('button')).find((item) =>
item.textContent?.includes('确认本组 2 张表'),
) as HTMLButtonElement | undefined;
expect(groupButton).not.toBeNull();
await act(async () => {
groupButton?.click();
});
const confirmButton = Array.from(document.body.querySelectorAll('button')).find(
(item) => item.textContent?.trim() === '确认导入',
) as HTMLButtonElement | undefined;
expect(confirmButton).toBeDefined();
await act(async () => {
confirmButton?.click();
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(submittedGroup).toEqual({ reviewId: 'review-2', type: 'checkins' });
});
it('renders legacy review sections missing type by inferring from key', async () => {
const review: AiReviewSchema = {
id: 'review-3',
title: '旧数据预览',
status: 'pending',
sections: [
{
key: 'checkins_legacy',
title: '旧入住表',
kind: 'table',
columns: [{ key: 'name', title: '姓名' }],
rows: [{ name: '张三' }],
issues: [],
},
],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<DynamicReview review={review} onSubmit={() => undefined} />);
});
expect(container.textContent).toContain('入住记录 · 共 1 张表');
expect(container.textContent).toContain('旧入住表');
});
it('renders an expired review card with table content but disabled actions', async () => {
let confirmed = false;
const review: AiReviewSchema = {
id: 'review-4',
title: '已被替代的预览',
status: 'expired',
sections: [
{
key: 'checkins_old',
type: 'checkins',
title: '旧入住表',
kind: 'table',
columns: [{ key: 'name', title: '姓名' }],
rows: [{ name: '张三' }],
issues: [],
},
],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<DynamicReview
review={review}
onSubmit={() => {
confirmed = true;
}}
onConfirmStep={() => {
confirmed = true;
}}
/>,
);
});
expect(container.textContent).toContain('已失效');
expect(container.textContent).toContain('已被新的预览替代');
expect(container.textContent).toContain('张三');
expect(container.textContent).not.toContain('全部确认并入库');
const disabledButton = Array.from(container.querySelectorAll('button')).find(
(item) => item.textContent?.trim() === '已失效',
) as HTMLButtonElement | undefined;
expect(disabledButton).toBeDefined();
expect(disabledButton?.disabled).toBe(true);
await act(async () => {
disabledButton?.click();
});
expect(confirmed).toBe(false);
});
it('renders an A2UI chart card with title and chart container', async () => {
const chart: AiChartSchema = {
id: 'chart-1',
title: '各班级人数',
chartType: 'bar',
columns: [
{ key: 'className', title: '班级' },
{ key: 'count', title: '人数' },
],
rows: [
{ className: '一班', count: 20 },
{ className: '二班', count: 15 },
],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<DynamicChart chart={chart} />);
});
expect(container.textContent).toContain('各班级人数');
expect(container.textContent).toContain('柱状图');
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
expect(container.querySelector('.ai-chat-chart-card__download')).not.toBeNull();
});
it('renders charts persisted on an assistant message', async () => {
const message: AiChatMessage = {
role: 'assistant',
content: '这是学生性别比例图',
reasoningContent: '',
toolRuns: [],
attachments: [],
charts: [
{
id: 'chart-9',
title: '学生性别比例',
chartType: 'pie',
columns: [
{ key: 'gender', title: '性别' },
{ key: 'count', title: '人数' },
],
rows: [
{ gender: '男', count: 2 },
{ gender: '女', count: 0 },
{ gender: '未填写', count: 61 },
],
},
],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<AiMessageContent message={message} />);
});
expect(container.textContent).toContain('学生性别比例');
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
});
it('renders source references from assistant message metadata', async () => {
const message: AiChatMessage = {
role: 'assistant',
content: '这是基于你上传的名单整理的入住统计。',
reasoningContent: '',
toolRuns: [],
attachments: [],
metadata: {
a2uiSources: [
{ title: '26暑期文化课宿舍.xlsx', url: '/api/ai/chat/attachments/7', description: 'excel' },
],
},
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<AiMessageContent message={message} />);
});
expect(container.textContent).toContain('引用来源');
expect(container.textContent).toContain('26暑期文化课宿舍.xlsx');
});
it('renders model retrying hint while waiting for the upstream retry', async () => {
const message: AiChatMessage = {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
retrying: { attempt: 2, maxRetries: 3, reason: '上游返回 503' },
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<AiMessageContent message={message} />);
});
expect(container.textContent).toContain('正在自动重试(第 2 / 3 次)');
expect(container.textContent).toContain('上游返回 503');
});
it.each([
['area', '面积图', [{ key: 'month', title: '月份' }, { key: 'amount', title: '金额' }], [
{ month: '1月', amount: 100 },
{ month: '2月', amount: 150 },
]],
['scatter', '散点图', [
{ key: 'room', title: '宿舍' },
{ key: 'capacity', title: '容量' },
{ key: 'occupied', title: '入住人数' },
], [
{ room: '1-101', capacity: 4, occupied: 3 },
{ room: '1-102', capacity: 6, occupied: 5 },
]],
['radar', '雷达图', [
{ key: 'className', title: '班级' },
{ key: 'attendance', title: '考勤' },
{ key: 'score', title: '成绩' },
], [
{ className: '一班', attendance: 90, score: 85 },
{ className: '二班', attendance: 80, score: 92 },
]],
['gauge', '仪表盘', [
{ key: 'metric', title: '指标' },
{ key: 'value', title: '数值' },
{ key: 'max', title: '最大值' },
], [
{ metric: '入住率', value: 82, max: 100 },
]],
['funnel', '漏斗图', [
{ key: 'stage', title: '阶段' },
{ key: 'count', title: '人数' },
], [
{ stage: '咨询', count: 100 },
{ stage: '报名', count: 60 },
]],
])('渲染 %s 图表卡片', async (chartType, label, columns, rows) => {
const chart: AiChartSchema = {
id: `chart-${chartType}`,
title: `${label}示例`,
chartType: chartType as AiChartSchema['chartType'],
columns,
rows,
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<DynamicChart chart={chart} />);
});
expect(container.textContent).toContain(`${label}示例`);
expect(container.textContent).toContain(label);
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
});
});

View File

@@ -52,4 +52,93 @@ describe('AI chat history mapper', () => {
expect(mapHistoryMessage({ ...base, status: 'failed' }).status).toBe('error');
expect(mapHistoryMessage({ ...base, status: 'cancelled' }).status).toBe('abort');
});
it('restores a persisted A2UI form from message metadata', () => {
const mapped = mapHistoryMessage({
id: 5,
role: 'assistant',
content: '请填写表单',
reasoningContent: null,
status: 'completed',
errorCode: null,
createdAt: '2026-07-23T00:00:00.000Z',
metadata: {
a2uiForm: {
id: 'form-9',
title: '新增学生',
submitLabel: '提交创建',
status: 'pending',
fields: [
{ name: 'name', label: '姓名', type: 'input', required: true },
{ name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] },
],
},
},
});
expect(mapped.message.forms).toHaveLength(1);
expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' });
});
it('restores a persisted A2UI review from message metadata', () => {
const mapped = mapHistoryMessage({
id: 6,
role: 'assistant',
content: '请审阅导入预览',
reasoningContent: null,
status: 'completed',
errorCode: null,
createdAt: '2026-07-23T00:00:00.000Z',
metadata: {
a2uiReview: {
id: 'review-9',
title: '批量导入',
status: 'pending',
sections: [
{
key: 'transfers',
type: 'transfers',
title: '换宿',
kind: 'table',
columns: [{ key: 'newRoom', title: '目标宿舍' }],
rows: [{ newRoom: '3-301' }],
issues: [],
},
],
},
},
});
expect(mapped.message.reviews).toHaveLength(1);
expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
});
it('restores persisted A2UI charts from message metadata', () => {
const mapped = mapHistoryMessage({
id: 7,
role: 'assistant',
content: '这是图表',
reasoningContent: null,
status: 'completed',
errorCode: null,
createdAt: '2026-07-23T00:00:00.000Z',
metadata: {
a2uiChart: [
{
id: 'chart-9',
title: '各班级人数',
chartType: 'bar',
columns: [
{ key: 'className', title: '班级' },
{ key: 'count', title: '人数' },
],
rows: [{ className: '一班', count: 20 }],
},
],
},
});
expect(mapped.message.charts).toHaveLength(1);
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
});
});

View File

@@ -1,5 +1,13 @@
import type { MessageInfo } from '@ant-design/x-sdk';
import type { AiChatMessage, AiChatMessageStatus, AiMessageRecord, AiToolRun } from './types';
import type {
AiChatMessage,
AiChatMessageStatus,
AiChartSchema,
AiFormSchema,
AiMessageRecord,
AiReviewSchema,
AiToolRun,
} from './types';
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
if (record.status === 'pending') return 'loading';
@@ -16,6 +24,32 @@ function normalizeToolRun(tool: AiToolRun): AiToolRun {
};
}
function historyForms(record: AiMessageRecord): AiFormSchema[] | undefined {
const a2uiForm = record.metadata?.a2uiForm;
if (!a2uiForm || typeof a2uiForm !== 'object' || Array.isArray(a2uiForm)) return undefined;
return [a2uiForm as AiFormSchema];
}
function historyReviews(record: AiMessageRecord): AiReviewSchema[] | undefined {
const a2uiReview = record.metadata?.a2uiReview;
if (!a2uiReview || typeof a2uiReview !== 'object' || Array.isArray(a2uiReview)) {
return undefined;
}
return [a2uiReview as AiReviewSchema];
}
function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {
const a2uiChart = record.metadata?.a2uiChart;
if (Array.isArray(a2uiChart)) {
return a2uiChart.filter(
(item): item is AiChartSchema =>
Boolean(item) && typeof item === 'object' && typeof (item as AiChartSchema).id === 'string',
);
}
if (!a2uiChart || typeof a2uiChart !== 'object') return undefined;
return [a2uiChart as AiChartSchema];
}
export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> {
return {
id: record.id,
@@ -27,6 +61,9 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMe
reasoningContent: record.reasoningContent || '',
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
attachments: record.attachments ?? [],
forms: historyForms(record),
reviews: historyReviews(record),
charts: historyCharts(record),
replyToMessageId: record.replyToMessageId,
feedback: record.feedback,
feedbackReason: record.feedbackReason,

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { parseSsePayload, reduceAiSseMessage } from './provider';
import { describe, expect, it, vi } from 'vitest';
import {
authenticatedFetch,
GongxueAiChatProvider,
parseSsePayload,
reduceAiSseMessage,
} from './provider';
describe('AI chat SSE message reducer', () => {
it('separates reasoning and answer deltas', () => {
@@ -120,6 +125,362 @@ describe('AI chat SSE message reducer', () => {
expect(message.id).toBe(9);
});
it('merges ui.form events into the assistant message by id', () => {
const form = {
id: 'form-1',
title: '新增学生',
submitLabel: '提交创建',
fields: [
{ name: 'name', label: '姓名', type: 'input', required: true },
{ name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] },
],
};
let message = reduceAiSseMessage(undefined, {
event: 'ui.form',
data: JSON.stringify({ messageId: 8, form }),
});
message = reduceAiSseMessage(message, {
event: 'ui.form',
data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }),
});
message = reduceAiSseMessage(message, {
event: 'ui.form',
data: JSON.stringify({
messageId: 8,
form: { id: 'form-2', title: '入住确认', fields: [] },
}),
});
expect(message.forms).toHaveLength(2);
expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' });
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
});
it('restores a persisted form from message.completed metadata', () => {
const message = reduceAiSseMessage(undefined, {
event: 'message.completed',
data: JSON.stringify({
message: {
id: 12,
content: '请填写表单',
status: 'completed',
metadata: {
a2uiForm: {
id: 'form-9',
title: '新增学生',
fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
},
},
},
}),
});
expect(message.forms).toHaveLength(1);
expect(message.forms?.[0].id).toBe('form-9');
});
it('merges ui.review events into the assistant message and updates by id', () => {
const review = {
id: 'review-1',
title: '开学导入',
summary: '来自报名 Excel',
status: 'pending',
sections: [
{
key: 'students',
type: 'students',
title: '学生',
kind: 'table',
columns: [
{ key: 'name', title: '姓名' },
{ key: 'phone', title: '手机号' },
],
rows: [{ name: '张三', phone: '13800138000' }],
issues: [],
},
],
};
let message = reduceAiSseMessage(undefined, {
event: 'ui.review',
data: JSON.stringify({ messageId: 8, review }),
});
message = reduceAiSseMessage(message, {
event: 'ui.review',
data: JSON.stringify({
messageId: 8,
review: { ...review, status: 'submitted', resultSummary: '{"students":{"created":1}}' },
}),
});
expect(message.reviews).toHaveLength(1);
expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' });
});
it('shows model retrying state and clears it when content starts', () => {
let message = reduceAiSseMessage(undefined, {
event: 'model.retrying',
data: JSON.stringify({
messageId: 8,
retry: { attempt: 1, maxRetries: 3, delayMs: 500, reason: '上游返回 503' },
}),
});
expect(message.retrying).toMatchObject({ attempt: 1, maxRetries: 3 });
message = reduceAiSseMessage(message, {
event: 'content.delta',
data: JSON.stringify({ messageId: 8, delta: '你好' }),
});
expect(message.retrying).toBeNull();
expect(message.content).toContain('你好');
});
it('restores a persisted review from message.completed metadata', () => {
const message = reduceAiSseMessage(undefined, {
event: 'message.completed',
data: JSON.stringify({
message: {
id: 12,
content: '请审阅',
status: 'completed',
metadata: {
a2uiReview: {
id: 'review-9',
title: '批量导入',
status: 'pending',
sections: [
{
key: 'rooms',
type: 'rooms',
title: '宿舍',
kind: 'table',
columns: [{ key: 'roomNumber', title: '房间号' }],
rows: [{ roomNumber: '3-301' }],
issues: [],
},
],
},
},
},
}),
});
expect(message.reviews).toHaveLength(1);
expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
});
it('merges ui.chart events into the assistant message by id', () => {
const chart = {
id: 'chart-1',
title: '各班级人数',
chartType: 'bar',
columns: [
{ key: 'className', title: '班级' },
{ key: 'count', title: '人数' },
],
rows: [
{ className: '一班', count: 20 },
{ className: '二班', count: 15 },
],
};
let message = reduceAiSseMessage(undefined, {
event: 'ui.chart',
data: JSON.stringify({ messageId: 8, chart }),
});
message = reduceAiSseMessage(message, {
event: 'ui.chart',
data: JSON.stringify({
messageId: 8,
chart: { ...chart, id: 'chart-2', title: '女生人数' },
}),
});
expect(message.charts).toHaveLength(2);
expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' });
expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' });
});
it('restores persisted charts from message.completed metadata', () => {
const message = reduceAiSseMessage(undefined, {
event: 'message.completed',
data: JSON.stringify({
message: {
id: 12,
content: '这是图表',
status: 'completed',
metadata: {
a2uiChart: [
{
id: 'chart-9',
title: '男女比例',
chartType: 'pie',
columns: [
{ key: 'name', title: '性别' },
{ key: 'value', title: '人数' },
],
rows: [
{ name: '男', value: 20 },
{ name: '女', value: 15 },
],
},
],
},
},
}),
});
expect(message.charts).toHaveLength(1);
expect(message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'pie' });
});
it('rewrites review submissions to the review submit stream endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
try {
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: '确认批量导入',
attachmentIds: [],
skillKey: null,
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
reasoningEffort: 'high',
reviewSubmission: { reviewId: 'review-1', reviewTitle: '开学导入' },
}),
});
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://x/api/ai/chat/reviews/review-1/submit/stream',
);
const body = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
) as Record<string, unknown>;
expect(body).toEqual({
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
reasoningEffort: 'high',
});
} finally {
vi.unstubAllGlobals();
}
});
it('keeps reasoningEffort when rewriting regenerate requests', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
try {
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: '',
attachmentIds: [],
skillKey: null,
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
reasoningEffort: 'high',
regenerateMessageId: 99,
}),
});
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://x/api/ai/chat/conversations/3/messages/99/regenerate/stream',
);
const body = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
) as Record<string, unknown>;
expect(body).toEqual({
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
reasoningEffort: 'high',
});
} finally {
vi.unstubAllGlobals();
}
});
it('keeps reasoningEffort when rewriting form submissions', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
try {
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: '',
attachmentIds: [],
skillKey: null,
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
reasoningEffort: 'high',
formSubmission: { formId: 'form-1', values: { name: '张三' } },
}),
});
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://x/api/ai/chat/forms/form-1/submit/stream',
);
const body = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
) as Record<string, unknown>;
expect(body).toEqual({
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
values: { name: '张三' },
reasoningEffort: 'high',
});
} finally {
vi.unstubAllGlobals();
}
});
it('routes submit-time ui.review to the original message instead of the streaming one', () => {
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
const onExternalReview = vi.fn();
provider.onExternalReview = onExternalReview;
const review = {
id: 'review-1',
title: '批量导入',
status: 'submitted',
sections: [],
};
const origin = {
id: 13,
role: 'assistant' as const,
content: '生成中',
reasoningContent: '',
toolRuns: [],
attachments: [],
reviews: [],
};
const next = provider.transformMessage({
originMessage: origin,
chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) },
status: 'updating',
chunks: [],
responseHeaders: {} as Headers,
});
expect(onExternalReview).toHaveBeenCalledWith(12, review);
expect(next).toBe(origin);
expect(next.reviews ?? []).toHaveLength(0);
});
it('routes ui.review without an origin message to the external handler', () => {
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
const onExternalReview = vi.fn();
provider.onExternalReview = onExternalReview;
const next = provider.transformMessage({
chunk: {
event: 'ui.review',
data: JSON.stringify({
messageId: 12,
review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] },
}),
},
status: 'updating',
chunks: [],
responseHeaders: {} as Headers,
});
expect(onExternalReview).toHaveBeenCalledWith(
12,
expect.objectContaining({ id: 'review-1' }),
);
expect(next.reviews ?? []).toHaveLength(0);
});
it('tolerates non-JSON event data', () => {
expect(parseSsePayload({ event: 'content.delta', data: 'plain text' })).toEqual({
event: 'content.delta',

View File

@@ -4,10 +4,16 @@ import {
type TransformMessage,
type XRequestOptions,
} from '@ant-design/x-sdk';
import { usePermissionStore } from '../../store/permission/permissionStore';
import { useUserStore } from '../../store/user/userStore';
import type {
AiAttachment,
AiChatInput,
AiChatMessage,
AiChartSchema,
AiFormSchema,
AiModelRetryInfo,
AiReviewSchema,
AiSseChunk,
AiToolRun,
} from './types';
@@ -26,6 +32,10 @@ interface AiSsePayload {
summary?: string | null;
durationMs?: number | null;
attachment?: AiAttachment;
form?: AiFormSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
retry?: AiModelRetryInfo;
message?:
| string
| {
@@ -50,9 +60,63 @@ function emptyAssistant(): AiChatMessage {
reasoningContent: '',
toolRuns: [],
attachments: [],
forms: [],
};
}
function mergeForms(
current: AiFormSchema[] | undefined,
incoming: AiFormSchema | AiFormSchema[] | undefined,
): AiFormSchema[] {
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' && !next.some((existing) => existing.id === item.id)) {
next.push(item);
}
}
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[] {
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;
}
export function parseSsePayload(chunk?: AiSseChunk): {
event: string;
payload: AiSsePayload;
@@ -115,14 +179,36 @@ 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;
} else if (event === 'reasoning.delta') {
message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
} else if (event === 'content.delta') {
message.retrying = null;
message.content += payload.delta ?? payload.content ?? '';
} else if (event === 'model.retrying' && payload.retry) {
message.retrying = payload.retry;
} 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);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeCharts(message.charts, payload.chart);
} else if (event === 'tool.started') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
} else if (event === 'tool.completed') {
@@ -141,14 +227,29 @@ 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;
message.retrying = null;
} else if (event === 'message.cancelled') {
message.id = payload.messageId ?? message.id;
message.cancelled = true;
message.retrying = null;
} else if (event === 'error') {
message.retrying = null;
message.error =
(typeof payload.message === 'string' ? payload.message : undefined) ||
payload.error ||
@@ -157,9 +258,12 @@ export function reduceAiSseMessage(
return message;
}
async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
export async function authenticatedFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const headers = new Headers(init?.headers);
const token = localStorage.getItem('token');
const token = useUserStore.getState().token;
if (token) headers.set('Authorization', `Bearer ${token}`);
headers.set('Accept', 'text/event-stream');
let requestInput = input;
@@ -171,13 +275,37 @@ async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit):
requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.regenerateMessageId}/regenerate/stream`;
requestInit = {
...init,
body: JSON.stringify({ clientRequestId: body.clientRequestId }),
body: JSON.stringify({
clientRequestId: body.clientRequestId,
reasoningEffort: body.reasoningEffort,
}),
};
} else if (body.formSubmission) {
requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/forms/${body.formSubmission.formId}/submit/stream`;
requestInit = {
...init,
body: JSON.stringify({
clientRequestId: body.clientRequestId,
values: body.formSubmission.values,
reasoningEffort: body.reasoningEffort,
}),
};
} else if (body.reviewSubmission) {
requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/reviews/${body.reviewSubmission.reviewId}/submit/stream`;
requestInit = {
...init,
body: JSON.stringify({
clientRequestId: body.clientRequestId,
reasoningEffort: body.reasoningEffort,
}),
};
} else {
const {
localAttachments: _localAttachments,
reloadMessage: _reloadMessage,
regenerateMessageId: _regenerateMessageId,
formSubmission: _formSubmission,
reviewSubmission: _reviewSubmission,
...payload
} = body;
requestInit = { ...init, body: JSON.stringify(payload) };
@@ -188,9 +316,8 @@ async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit):
}
const response = await fetch(requestInput, { ...requestInit, headers });
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
useUserStore.getState().logout();
usePermissionStore.getState().clearPermissions();
window.location.href = '/login';
}
return response;
@@ -201,17 +328,27 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
AiChatInput,
AiSseChunk
> {
constructor(url: string, onSettled?: () => void) {
/** Routes events that target another (already streamed) message. */
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
constructor(
url: string,
onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void,
) {
super({
request: XRequest<AiChatInput, AiSseChunk, AiChatMessage>(url, {
manual: true,
fetch: authenticatedFetch,
timeout: 15_000,
streamTimeout: 120_000,
streamTimeout: 1_800_000,
callbacks: {
onUpdate: () => undefined,
onSuccess: () => onSettled?.(),
onError: () => onSettled?.(),
onSuccess: () => onSettled?.({ ok: true }),
onError: (error) =>
onSettled?.({
ok: false,
aborted: error?.name === 'AbortError',
}),
},
}),
});
@@ -227,13 +364,44 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
attachmentIds: requestParams.attachmentIds ?? [],
skillKey: requestParams.skillKey ?? null,
clientRequestId: requestParams.clientRequestId || crypto.randomUUID(),
reasoningEffort: requestParams.reasoningEffort,
localAttachments: requestParams.localAttachments,
formSubmission: requestParams.formSubmission,
reviewSubmission: requestParams.reviewSubmission,
regenerateMessageId: requestParams.regenerateMessageId,
reloadMessage: requestParams.reloadMessage,
};
}
transformLocalMessage(requestParams: Partial<AiChatInput>): AiChatMessage {
if (requestParams.formSubmission) {
return {
role: 'user',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: requestParams.localAttachments ?? [],
metadata: {
a2uiSubmit: {
formTitle: requestParams.formSubmission.formTitle,
},
},
};
}
if (requestParams.reviewSubmission) {
return {
role: 'user',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: requestParams.localAttachments ?? [],
metadata: {
a2uiReviewSubmit: {
reviewTitle: requestParams.reviewSubmission.reviewTitle,
},
},
};
}
return {
role: 'user',
content: requestParams.message?.trim() || '',
@@ -244,6 +412,18 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
}
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
const { event, payload } = parseSsePayload(info.chunk);
if (
event === 'ui.review' &&
payload.review &&
typeof payload.messageId === 'number' &&
info.originMessage?.id !== payload.messageId
) {
// The submitted review belongs to the original assistant message;
// do not merge it into the message currently being streamed.
this.onExternalReview?.(payload.messageId, payload.review);
return info.originMessage ?? emptyAssistant();
}
return reduceAiSseMessage(info.originMessage, info.chunk);
}
}

View File

@@ -8,6 +8,24 @@
gap: 8px;
}
.ai-chat-sender-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ai-chat-sender-footer {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ai-chat-sender-footer .ant-sender-switch {
margin-inline: 0;
}
.ai-chat-layout {
position: relative;
display: flex;
@@ -18,6 +36,8 @@
.ai-chat-sidebar {
position: relative;
display: flex;
flex-direction: column;
flex: 0 0 0;
width: 0;
min-width: 0;
@@ -35,7 +55,9 @@
.ai-chat-sidebar .ant-conversations {
width: 232px;
height: 100%;
flex: 1;
min-height: 0;
height: auto;
overflow-y: auto;
}
@@ -43,11 +65,98 @@
margin-bottom: 8px;
}
.ai-chat-conversation-label {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.ai-chat-conversation-label__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-chat-conversation-check {
flex: none;
pointer-events: none;
margin-inline-end: 2px;
}
/* 运行中指示:使用 antd LoadingOutlined 旋转图标 */
.ai-chat-conversation-loading {
color: #007aff;
font-size: 12px;
flex: none;
}
.ai-chat-streaming-placeholder {
display: inline-flex;
align-items: center;
color: #007aff;
font-size: 16px;
padding: 4px 2px;
}
/* 失败 / 已停止:极简状态文字,不再使用 Tag */
.ai-chat-conversation-state {
display: inline-flex;
align-items: center;
gap: 4px;
flex: none;
font-size: 12px;
line-height: 1;
}
.ai-chat-conversation-state i {
width: 5px;
height: 5px;
border-radius: 50%;
}
.ai-chat-conversation-state.is-error {
color: #ff4d4f;
}
.ai-chat-conversation-state.is-error i {
background: #ff4d4f;
}
.ai-chat-conversation-state.is-stopped {
color: #8c8c8c;
}
.ai-chat-conversation-state.is-stopped i {
background: #bfbfbf;
}
.ai-chat-sidebar__loading {
position: absolute;
inset: 68px 0 auto;
}
.ai-chat-sidebar__footer {
flex: none;
display: flex;
align-items: center;
gap: 2px;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid #f0f0f0;
min-width: 0;
}
.ai-chat-sidebar__selected-count {
margin-right: auto;
padding: 0 4px;
font-size: 12px;
color: #8c8c8c;
white-space: nowrap;
}
.ai-chat-main {
display: flex;
flex: 1 1 auto;
@@ -143,7 +252,7 @@
border-top: 1px solid #ededf0;
}
.ai-chat-composer .ant-sender {
.ai-chat-composer > .ant-sender {
max-width: 820px;
margin: 0 auto;
}
@@ -159,6 +268,16 @@
box-shadow: none;
}
.ai-chat-composer .ant-sender-prefix {
display: flex;
align-items: center;
align-self: center;
}
.ai-chat-composer .ant-sender-prefix .ant-btn {
color: #8a8f99;
}
.ai-chat-disclaimer {
display: block;
margin-top: 6px;
@@ -191,3 +310,125 @@
max-width: 92%;
}
}
.ai-chat-dynamic-form {
margin-top: 10px;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fafafa;
max-width: 420px;
}
.ai-chat-dynamic-form__desc {
font-size: 12px;
}
.ai-chat-dynamic-form .ant-form-item {
margin-bottom: 10px;
}
.ai-chat-dynamic-form__number,
.ai-chat-dynamic-form__date {
width: 100%;
}
.ai-chat-dynamic-form__error {
margin-bottom: 10px;
}
.ai-chat-review {
width: 100%;
min-width: 0;
}
.ai-chat-review-card {
margin-top: 10px;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
}
.ai-chat-review-card__title {
font-size: 15px;
}
.ai-chat-review-card__summary {
margin: 4px 0 8px !important;
font-size: 12px;
}
.ai-chat-review-card__footer {
margin-top: 6px;
padding-top: 10px;
border-top: 1px dashed #e5e7eb;
}
.ai-chat-review-card__step {
margin-top: 10px;
}
.ai-chat-review-card__group {
margin-top: 12px;
padding: 10px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fafafa;
}
.ai-chat-review-card__sheets {
padding: 4px 0;
}
.ai-chat-review-card__sheet {
padding: 8px 10px;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fff;
cursor: pointer;
transition: border-color 0.2s;
}
.ai-chat-review-card__sheet:hover {
border-color: #1677ff;
}
.ai-chat-review-card__step-result {
font-size: 12px;
}
.ai-chat-review-card__step-error {
margin-top: 8px;
}
.ai-chat-review__issues {
margin: 4px 0 0;
padding-left: 18px;
font-size: 12px;
}
.ai-chat-review__error {
margin-top: 8px;
}
.ai-chat-chart {
width: 100%;
min-width: 0;
}
.ai-chat-chart-card {
margin-top: 10px;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
}
.ai-chat-chart-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}

View File

@@ -31,6 +31,73 @@ export interface AiAttachment {
createdAt: string;
}
export interface AiFormFieldOption {
label: string;
value: string;
}
export interface AiFormField {
name: string;
label: string;
type: 'input' | 'textarea' | 'number' | 'select' | 'date';
required?: boolean;
placeholder?: string;
defaultValue?: string | number;
options?: AiFormFieldOption[];
}
export interface AiFormSchema {
id: string;
title: string;
description?: string | null;
submitLabel?: string;
fields: AiFormField[];
status?: 'pending' | 'submitted';
}
export interface AiReviewColumn {
key: string;
title: string;
}
export interface AiReviewRow {
[key: string]: string | number | boolean | null;
}
export type AiReviewSectionStatus = 'pending' | 'submitted' | 'failed' | 'skipped';
export type AiReviewSectionType = 'students' | 'rooms' | 'transfers' | 'checkins';
export interface AiReviewSection {
key: string;
type?: AiReviewSectionType;
title: string;
kind: 'table';
sheet?: string;
columns: AiReviewColumn[];
rows: AiReviewRow[];
issues: string[];
status?: AiReviewSectionStatus;
resultSummary?: string | null;
submittedAt?: string | null;
}
export interface AiReviewSchema {
id: string;
title: string;
summary?: string | null;
sections: AiReviewSection[];
status?: 'pending' | 'submitted' | 'expired';
resultSummary?: string | null;
}
export interface AiChartSchema {
id: string;
title: string;
chartType: 'line' | 'bar' | 'pie' | 'area' | 'scatter' | 'radar' | 'gauge' | 'funnel';
columns: AiReviewColumn[];
rows: AiReviewRow[];
}
export type AiToolRunStatus =
| 'running'
| 'success'
@@ -51,6 +118,13 @@ export interface AiToolRun {
durationMs?: number | null;
}
export interface AiModelRetryInfo {
attempt: number;
maxRetries: number;
delayMs?: number;
reason?: string;
}
export type AiMessageRole = 'user' | 'assistant';
export type AiMessageFeedback = 'like' | 'dislike' | null;
@@ -61,10 +135,14 @@ export interface AiChatMessage {
reasoningContent: string;
toolRuns: AiToolRun[];
attachments: AiAttachment[];
forms?: AiFormSchema[];
reviews?: AiReviewSchema[];
charts?: AiChartSchema[];
replyToMessageId?: number | null;
feedback?: AiMessageFeedback;
feedbackReason?: string | null;
metadata?: Record<string, unknown> | null;
retrying?: AiModelRetryInfo | null;
error?: string;
cancelled?: boolean;
}
@@ -97,7 +175,17 @@ export interface AiChatInput {
attachmentIds: number[];
skillKey: string | null;
clientRequestId: string;
reasoningEffort?: string | null;
localAttachments?: AiAttachment[];
formSubmission?: {
formId: string;
values: Record<string, unknown>;
formTitle?: string;
};
reviewSubmission?: {
reviewId: string;
reviewTitle?: string;
};
regenerateMessageId?: number;
reloadMessage?: AiChatMessage;
}

View File

@@ -1,11 +1,22 @@
import React, { useEffect, useRef } from 'react';
import * as echarts from 'echarts/core';
import type { EChartsType } from 'echarts/core';
export type EChartsOption = Record<string, unknown>;
import { BarChart, CustomChart, LineChart, PieChart } from 'echarts/charts';
import {
BarChart,
CustomChart,
FunnelChart,
GaugeChart,
LineChart,
PieChart,
RadarChart,
ScatterChart,
} from 'echarts/charts';
import {
DataZoomComponent,
GridComponent,
LegendComponent,
RadarComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
@@ -14,11 +25,16 @@ import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
BarChart,
CustomChart,
FunnelChart,
GaugeChart,
LineChart,
PieChart,
RadarChart,
ScatterChart,
DataZoomComponent,
GridComponent,
LegendComponent,
RadarComponent,
TooltipComponent,
VisualMapComponent,
CanvasRenderer,
@@ -28,15 +44,20 @@ interface EChartsProps {
option: EChartsOption;
style?: React.CSSProperties;
className?: string;
/** 图表实例就绪回调(用于导出图片等场景) */
onReady?: (chart: EChartsType) => void;
}
const ECharts: React.FC<EChartsProps> = ({ option, style, className }) => {
const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => {
const containerRef = useRef<HTMLDivElement>(null);
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
useEffect(() => {
if (!containerRef.current) return;
const chart = echarts.init(containerRef.current);
chart.setOption(option);
onReadyRef.current?.(chart);
const observer = new ResizeObserver(() => chart.resize());
observer.observe(containerRef.current);
return () => {