From 50c44e4410cd58d74ac0347a74f311b1f980b726 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 4 Aug 2026 14:41:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E5=AF=B9=E8=AF=9D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20A2UI=20=E8=A1=A8=E5=8D=95/=E5=AE=A1=E6=9F=A5/?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E4=B8=8E=20Excel=20=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/AiChat/AiChatDrawer.tsx | 612 +++++- .../components/AiChat/AiMessageContent.tsx | 141 +- .../src/components/AiChat/DynamicChart.tsx | 295 +++ .../src/components/AiChat/DynamicForm.tsx | 239 +++ .../src/components/AiChat/DynamicReview.tsx | 699 +++++++ .../components/AiChat/api.integration.test.ts | 34 + apps/admin/src/components/AiChat/api.ts | 23 + .../AiChat/bubble.integration.test.tsx | 439 +++- .../message-mappers.integration.test.ts | 89 + .../src/components/AiChat/message-mappers.ts | 39 +- .../AiChat/provider.integration.test.ts | 365 +++- apps/admin/src/components/AiChat/provider.ts | 200 +- apps/admin/src/components/AiChat/style.css | 245 ++- apps/admin/src/components/AiChat/types.ts | 88 + apps/admin/src/components/ECharts.tsx | 25 +- apps/admin/src/pages/AiConfig/index.tsx | 30 +- .../src/ai-chat/ai-attachment.service.spec.ts | 105 +- .../src/ai-chat/ai-attachment.service.ts | 124 +- .../src/ai-chat/ai-chart.service.spec.ts | 86 + apps/server/src/ai-chat/ai-chart.service.ts | 126 ++ apps/server/src/ai-chat/ai-chat.controller.ts | 75 + apps/server/src/ai-chat/ai-chat.module.ts | 34 +- .../src/ai-chat/ai-chat.service.spec.ts | 1420 ++++++++++++- apps/server/src/ai-chat/ai-chat.service.ts | 1457 ++++++++++++- apps/server/src/ai-chat/ai-chat.types.ts | 11 + .../src/ai-chat/ai-excel-reader.service.ts | 235 +++ .../src/ai-chat/ai-form.service.spec.ts | 150 ++ apps/server/src/ai-chat/ai-form.service.ts | 290 +++ .../ai-chat/ai-model-stream.service.spec.ts | 96 + .../src/ai-chat/ai-model-stream.service.ts | 117 +- .../ai-review-enlarge.migration.spec.ts | 63 + .../src/ai-chat/ai-review.service.spec.ts | 1310 ++++++++++++ apps/server/src/ai-chat/ai-review.service.ts | 1838 +++++++++++++++++ apps/server/src/ai-chat/dto/ai-chat.dto.ts | 31 + .../src/ai-chat/entities/ai-form.entity.ts | 79 + .../src/ai-chat/entities/ai-review.entity.ts | 95 + apps/server/src/ai-chat/entities/index.ts | 2 + .../src/ai-chat/office-cli.service.spec.ts | 19 + apps/server/src/ai-chat/office-cli.service.ts | 114 + apps/server/src/ai-config/ai-config.entity.ts | 3 + .../server/src/ai-config/ai-config.service.ts | 6 + .../server/src/ai-config/dto/ai-config.dto.ts | 11 + apps/server/src/app.module.ts | 8 + .../database/database-migrations.service.ts | 1 + .../src/database/database-migrations.spec.ts | 1 + apps/server/src/entities/index.ts | 9 +- apps/server/src/migration-runner.ts | 6 + .../migrations/1784870000000-AddA2UiForms.ts | 57 + .../1784880000000-AddA2UiReviews.ts | 56 + .../1784900000000-EnlargeAiReviewSections.ts | 24 + scripts/a2ui-contract.md | 141 ++ 51 files changed, 11588 insertions(+), 175 deletions(-) create mode 100644 apps/admin/src/components/AiChat/DynamicChart.tsx create mode 100644 apps/admin/src/components/AiChat/DynamicForm.tsx create mode 100644 apps/admin/src/components/AiChat/DynamicReview.tsx create mode 100644 apps/server/src/ai-chat/ai-chart.service.spec.ts create mode 100644 apps/server/src/ai-chat/ai-chart.service.ts create mode 100644 apps/server/src/ai-chat/ai-excel-reader.service.ts create mode 100644 apps/server/src/ai-chat/ai-form.service.spec.ts create mode 100644 apps/server/src/ai-chat/ai-form.service.ts create mode 100644 apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts create mode 100644 apps/server/src/ai-chat/ai-review.service.spec.ts create mode 100644 apps/server/src/ai-chat/ai-review.service.ts create mode 100644 apps/server/src/ai-chat/entities/ai-form.entity.ts create mode 100644 apps/server/src/ai-chat/entities/ai-review.entity.ts create mode 100644 apps/server/src/ai-chat/office-cli.service.spec.ts create mode 100644 apps/server/src/ai-chat/office-cli.service.ts create mode 100644 apps/server/src/migrations/1784870000000-AddA2UiForms.ts create mode 100644 apps/server/src/migrations/1784880000000-AddA2UiReviews.ts create mode 100644 apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts create mode 100644 scripts/a2ui-contract.md diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index e3b7d69..427e9bf 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -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 = ({ open, onClose }) => { +const AiChatDrawer: React.FC = ({ open, onClose, onRequestingChange }) => { const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const [loadingList, setLoadingList] = useState(false); @@ -99,9 +133,20 @@ const AiChatDrawer: React.FC = ({ open, onClose }) => { const [input, setInput] = useState(''); const [skills, setSkills] = useState([]); const [attachments, setAttachments] = useState([]); + const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking); + const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking); + const [conversationStatus, setConversationStatus] = useState< + Record + >({}); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); const requestingRef = useRef(false); const abortRef = useRef<() => void>(() => undefined); const attachmentsRef = useRef([]); + const requestAbortRef = useRef(new Map void>()); + const providersRef = useRef(new Map()); + const loadedRef = useRef(false); + const pendingDraftConversationIdRef = useRef(null); const { conversations, @@ -112,6 +157,7 @@ const AiChatDrawer: React.FC = ({ 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 = ({ 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 = ({ 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) => { + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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) => { - if (!activeId || typeof messageInfo.message.id !== 'number') return; - onReload(messageInfo.id, { - message: '', + reloadWithStatus(messageInfo); + }, + [reloadWithStatus], + ); + + const submitForm = useCallback( + (form: AiFormSchema, values: Record) => { + 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 => { + 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 => { + 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 = ({ 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( + () => + conversations.map((item) => { + const status = conversationStatus[item.id]; + let statusIndicator: React.ReactNode = null; + if (status === 'running') { + statusIndicator = ( + + ); + } else if (status === 'error' || status === 'stopped') { + statusIndicator = ( + + + {conversationStatusMeta(status).label} + + ); + } + const label = ( + + {selectionMode && ( + + )} + {item.title} + {statusIndicator} + + ); + return { ...item, label }; + }), + [conversationStatus, conversations, selectedKeys, selectionMode], ); const skillMenu: MenuProps = { @@ -423,13 +822,10 @@ const AiChatDrawer: React.FC = ({ open, onClose }) => { return ( 功学 AI 助手} + title={恭学 AI 助手} open={open} - onClose={() => { - stopRequest(); - discardPendingAttachments(); - onClose(); - }} + closeIcon={} + onClose={onClose} width={isMobile ? '100%' : 'min(1040px, 92vw)'} destroyOnHidden={false} className="ai-chat-drawer" @@ -438,16 +834,55 @@ const AiChatDrawer: React.FC = ({ open, onClose }) => {
@@ -473,8 +908,8 @@ const AiChatDrawer: React.FC = ({ open, onClose }) => { } - title="你好,我是功学 AI 助手" - description={lockedSkill?.description || '我会在你的权限范围内查询学生、考勤、宿舍、账单和经营数据。'} + title="你好,我是恭学 AI 助手" + description={lockedSkill?.description || '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'} /> = ({ open, onClose }) => { : undefined } header={ - uploadItems.length ? ( - - ) : false + uploadItems.length > 0 && ( +
+ +
+ ) } - prefix={ - - - + footer={ +
+ + +
} /> - AI 仅查询你有权限查看的数据,重要信息请以系统记录为准 + AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准
diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index 4c5e8dc..ea4df02 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -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 = { 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 {content}; + if (lang === 'mermaid') return {content}; return {content}; }, }; @@ -57,7 +86,7 @@ function attachmentIcon(attachment: AiAttachment) { } async function openAttachment(attachment: AiAttachment): Promise { - 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 { window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); } +async function openSourceUrl(item: { url?: string }): Promise { + 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( () => @@ -99,6 +140,18 @@ export interface AiMessageContentProps { status?: AiChatMessageStatus; onReload?: () => void; onFeedback?: (feedback: AiMessageFeedback) => void; + onSubmitForm?: (form: AiFormSchema, values: Record) => void; + onSubmitReview?: (reviewId: string, reviewTitle?: string) => void; + onConfirmReviewStep?: ( + messageId: number | undefined, + reviewId: string, + sectionKey: AiReviewSection['key'], + ) => AiReviewSchema | Promise | void; + onConfirmReviewGroup?: ( + messageId: number | undefined, + reviewId: string, + type: AiReviewSectionType, + ) => AiReviewSchema | Promise | void; } export const AiMessageContent: React.FC = ({ @@ -106,8 +159,28 @@ export const AiMessageContent: React.FC = ({ 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) => ( = ({ )); if (message.role === 'user') { + if (reviewSubmission && typeof reviewSubmission === 'object') { + const reviewTitle = + typeof (reviewSubmission as Record).reviewTitle === 'string' + ? String((reviewSubmission as Record).reviewTitle) + : '批量导入'; + return ( + + + + ); + } + if (formSubmission && typeof formSubmission === 'object') { + const formTitle = + typeof (formSubmission as Record).formTitle === 'string' + ? String((formSubmission as Record).formTitle) + : '表单'; + return ( + + + + ); + } return ( {attachmentCards.length > 0 && {attachmentCards}} @@ -158,6 +253,19 @@ export const AiMessageContent: React.FC = ({ return ( + {streaming && !message.content && !message.reasoningContent && message.toolRuns.length === 0 && ( +
+ +
+ )} + {message.retrying && ( + + )} {message.reasoningContent && ( = ({ }} /> )} + {sourceItems.length > 0 && ( + void openSourceUrl(item as { url?: string })} + /> + )} + {(message.forms ?? []).map((form) => ( + onSubmitForm?.(form, values)} + /> + ))} + {(message.reviews ?? []).map((review: AiReviewSchema) => ( + onSubmitReview?.(reviewId, review.title)} + onConfirmStep={onConfirmReviewStep} + onConfirmGroup={onConfirmReviewGroup} + /> + ))} + {(message.charts ?? []).map((chart: AiChartSchema) => ( + + ))} {message.error && } {message.cancelled && 回答已停止} {!streaming && message.content && } diff --git a/apps/admin/src/components/AiChat/DynamicChart.tsx b/apps/admin/src/components/AiChat/DynamicChart.tsx new file mode 100644 index 0000000..7d65542 --- /dev/null +++ b/apps/admin/src/components/AiChat/DynamicChart.tsx @@ -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 = { + 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 = ({ chart }) => { + const option = useMemo(() => (chart ? buildOption(chart) : {}), [chart]); + const [instance, setInstance] = useState(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 ( +
+
+ {chart.title} + + {CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType} + +
+ +
+ ); +}; + +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 = ({ chart }) => { + const commandsRef = useRef([]); + const [commands, setCommands] = useState([]); + const idRef = useRef(''); + + 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 ( +
+ + + +
+ ); +}; + +export default DynamicChart; diff --git a/apps/admin/src/components/AiChat/DynamicForm.tsx b/apps/admin/src/components/AiChat/DynamicForm.tsx new file mode 100644 index 0000000..fd7411a --- /dev/null +++ b/apps/admin/src/components/AiChat/DynamicForm.tsx @@ -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, +): Record { + const values: Record = {}; + 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) => 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 = ({ 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) => { + onAction?.('form:submit', { values: normalizeValues(form.fields, values) }); + }; + + return ( + + {form.title} + {form.description && ( + + {form.description} + + )} + {finished ? ( + + ) : ( +
void handleFinish(values as Record)} + disabled={disabled || submitting} + requiredMark={false} + > + {form.fields.map((field) => ( + + {field.type === 'textarea' ? ( + + ) : field.type === 'number' ? ( + + ) : field.type === 'select' ? ( + + )} + + ))} + {runtime.error && ( + + )} + + + )} +
+ ); +}; + +export interface DynamicFormProps { + form: AiFormSchema; + disabled?: boolean; + onSubmit: (values: Record) => void | Promise; +} + +/** + * 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 = ({ form, disabled, onSubmit }) => { + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + const commandsRef = useRef([]); + const [commands, setCommands] = useState([]); + const idRef = useRef(''); + + 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) => { + 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) + : {}; + void handleSubmit(values); + }; + + return ( +
+ + + +
+ ); +}; + +export default DynamicForm; diff --git a/apps/admin/src/components/AiChat/DynamicReview.tsx b/apps/admin/src/components/AiChat/DynamicReview.tsx new file mode 100644 index 0000000..1c303a5 --- /dev/null +++ b/apps/admin/src/components/AiChat/DynamicReview.tsx @@ -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 = { + students: '学生', + rooms: '宿舍', + transfers: '换宿', + checkins: '入住记录', +}; + +const SECTION_ORDER: AiReviewSectionType[] = [ + 'students', + 'rooms', + 'transfers', + 'checkins', +]; + +const SECTION_DEPENDENCIES: Record = { + students: [], + rooms: [], + transfers: ['students', 'rooms'], + checkins: [], +}; + +function sectionType(section: Pick): 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 = { + pending: '待确认', + submitted: '已导入', + failed: '失败', + skipped: '已跳过', +}; + +type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing'; + +const GROUP_STATUS_LABELS: Record = { + 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['columns'] = section.columns.map((column) => ({ + title: column.title, + dataIndex: column.key, + key: column.key, + ellipsis: true, + render: (value: unknown) => + value === null || value === undefined || value === '' ? ( + - + ) : ( + String(value) + ), + })); + return ( + + 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) => void; +} + +const ReviewPreview: React.FC = ({ 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 ( +
+ + + {review.title} + + {submitted ? ( + 已导入 + ) : expired ? ( + 已失效 + ) : anyRunning ? ( + 导入中 + ) : ( + 待确认 + )} + + {review.summary && ( + + {review.summary} + + )} + {expired && ( + + )} + 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 && ( + + + + + {SECTION_TYPE_LABELS[activeType]} · 共 {group.length} 张表 / {typeTotal} 行 + + + {GROUP_STATUS_LABELS[ + groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) + ]} + + + {groupDep && ( + + )} + {!submitted && !expired && group.length > 0 && ( + + onAction?.('review:confirmGroup', { + reviewId: review.id, + type: activeType, + }) + } + > + + + )} + + + {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 ( + + onAction?.('review:selectStep', { sectionKey: section.key }) + } + > + + + {index + 1}. {section.title} + {section.sheet ? ( + ({section.sheet}) + ) : null} + + + {sectionCount(section)} 行 · {SECTION_STATUS_LABELS[status]} + + + + + ); + })} + + {activeSection && ( + + {activeSection.issues.length > 0 && ( + + {activeSection.issues.slice(0, 20).map((issue, issueIndex) => ( +
  • {issue}
  • + ))} + + } + /> + )} + + {dependency && ( + + )} + {activeStatus === 'failed' && ( + + )} + {activeSection.resultSummary && activeStatus === 'submitted' && ( + + {sectionResultText(activeSection)} + + )} +
    + )} +
    + )} + + + 共 {allRows} 行,含 {allIssues.length} 条提示 + + {!submitted && !expired && ( + onAction?.('review:submit', { reviewId: review.id })} + > + + + )} + + {submitted && } + {runtime.error && ( + + )} +
    + ); +}; + +export interface DynamicReviewProps { + review: AiReviewSchema; + disabled?: boolean; + messageId?: number; + onSubmit: (reviewId: string) => void | Promise; + onConfirmStep?: ( + messageId: number | undefined, + reviewId: string, + sectionKey: string, + ) => AiReviewSchema | Promise | void; + onConfirmGroup?: ( + messageId: number | undefined, + reviewId: string, + type: AiReviewSectionType, + ) => AiReviewSchema | Promise | 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 = ({ + review, + disabled, + messageId, + onSubmit, + onConfirmStep, + onConfirmGroup, +}) => { + const [submitting, setSubmitting] = useState(false); + const [submittingKey, setSubmittingKey] = useState(null); + const [submittingGroup, setSubmittingGroup] = useState(false); + const [activeKey, setActiveKey] = useState(undefined); + const [activeType, setActiveType] = useState(undefined); + const [localReview, setLocalReview] = useState(review); + const [error, setError] = useState(null); + const commandsRef = useRef([]); + const [commands, setCommands] = useState([]); + const idRef = useRef(''); + + 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 ( +
    + + + + {error && } +
    + ); +}; + +export default DynamicReview; diff --git a/apps/admin/src/components/AiChat/api.integration.test.ts b/apps/admin/src/components/AiChat/api.integration.test.ts index e86f1bf..94d113e 100644 --- a/apps/admin/src/components/AiChat/api.integration.test.ts +++ b/apps/admin/src/components/AiChat/api.integration.test.ts @@ -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', + ); + }); }); diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index 9184640..68441e4 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -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>(`${basePath}/${id}`, input)).data, deleteConversation: (id: number) => api.delete(`${basePath}/${id}`), + deleteAllConversations: async () => + (await api.delete<{ success: boolean; data: { deleted: number } }>(basePath)).data, uploadAttachment: async (file: File): Promise => { 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 => + ( + await api.post>( + `/ai/chat/reviews/${reviewId}/steps/${sectionKey}/confirm`, + ) + ).data, + confirmReviewGroup: async ( + reviewId: string, + type: AiReviewSectionType, + ): Promise => + ( + await api.post>( + `/ai/chat/reviews/${reviewId}/types/${type}/confirm`, + ) + ).data, listMessages: async (id: number): Promise => { const first = ( await api.get>(`${basePath}/${id}/messages`, { diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 260ac89..1ae84ac 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -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 | 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(
    {content.content}
    , + }, + ]} />, ); }); expect(container.textContent).toContain('查询今天的系统概览'); }); + + it('renders an A2UI form and submits normalized values', async () => { + let submitted: Record | null = null; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + { + 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( + { + 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( + 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( 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( + { + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + expect(container.textContent).toContain(`${label}示例`); + expect(container.textContent).toContain(label); + expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull(); + }); }); diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 654f269..eb11697 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -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' }); + }); }); diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts index f8b52d7..2ee555e 100644 --- a/apps/admin/src/components/AiChat/message-mappers.ts +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -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 { return { id: record.id, @@ -27,6 +61,9 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo { 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; + 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; + 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; + 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', diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index 479697a..3467a60 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -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 { +export async function authenticatedFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { 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(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): 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 { + 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); } } diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css index 5e71f84..f79f073 100644 --- a/apps/admin/src/components/AiChat/style.css +++ b/apps/admin/src/components/AiChat/style.css @@ -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; +} diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index 6bea26e..b46d404 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -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 | 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; + formTitle?: string; + }; + reviewSubmission?: { + reviewId: string; + reviewTitle?: string; + }; regenerateMessageId?: number; reloadMessage?: AiChatMessage; } diff --git a/apps/admin/src/components/ECharts.tsx b/apps/admin/src/components/ECharts.tsx index 570a33b..047acca 100644 --- a/apps/admin/src/components/ECharts.tsx +++ b/apps/admin/src/components/ECharts.tsx @@ -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; -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 = ({ option, style, className }) => { +const ECharts: React.FC = ({ option, style, className, onReady }) => { const containerRef = useRef(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 () => { diff --git a/apps/admin/src/pages/AiConfig/index.tsx b/apps/admin/src/pages/AiConfig/index.tsx index 9e70e27..229204b 100644 --- a/apps/admin/src/pages/AiConfig/index.tsx +++ b/apps/admin/src/pages/AiConfig/index.tsx @@ -60,6 +60,7 @@ interface AiConfigData { enabled: boolean; supportsVision: boolean; timeoutMs: number; + reasoningEffort: string | null; verified: boolean; lastTestedAt: string | null; lastTestLatencyMs: number | null; @@ -99,6 +100,7 @@ interface FormValues { defaultModel: string; timeoutMs: number; supportsVision: boolean; + reasoningEffort: string; } const DEFAULT_FORM_VALUES: FormValues = { @@ -108,6 +110,7 @@ const DEFAULT_FORM_VALUES: FormValues = { defaultModel: '', timeoutMs: 30000, supportsVision: false, + reasoningEffort: '', }; // --------------------------------------------------------------------------- @@ -171,6 +174,7 @@ const AiConfigPage: React.FC = () => { defaultModel: res.data.defaultModel ?? '', timeoutMs: res.data.timeoutMs, supportsVision: res.data.supportsVision, + reasoningEffort: res.data.reasoningEffort ?? '', }; form.setFieldsValue(initial); setFormValues(initial); @@ -252,7 +256,8 @@ const AiConfigPage: React.FC = () => { // Validate fields (for UI error display) — actual values come from state await form.validateFields(['provider', 'baseUrl', 'timeoutMs']); - const { provider, baseUrl, defaultModel, apiKey, timeoutMs, supportsVision } = formValues; + const { provider, baseUrl, defaultModel, apiKey, timeoutMs, supportsVision, reasoningEffort } = + formValues; if (provider === 'OPENAI_COMPATIBLE' && !baseUrl) { message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL'); @@ -270,6 +275,7 @@ const AiConfigPage: React.FC = () => { enabled: true, supportsVision, timeoutMs, + reasoningEffort: reasoningEffort || null, }; if (apiKey && apiKey !== '••••') { @@ -308,11 +314,12 @@ const AiConfigPage: React.FC = () => { setTesting(true); setTestResult(null); - const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues; + const { provider, baseUrl, defaultModel, apiKey, timeoutMs, reasoningEffort } = formValues; const body: Record = { provider, timeoutMs }; if (baseUrl) body.baseUrl = baseUrl; if (defaultModel) body.defaultModel = defaultModel; if (apiKey && apiKey !== '••••') body.apiKey = apiKey; + if (reasoningEffort) body.reasoningEffort = reasoningEffort; const res = await api.post('/ai/config/test', body); setTestResult(res); @@ -582,6 +589,25 @@ const AiConfigPage: React.FC = () => { + +