diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx new file mode 100644 index 0000000..3bec7d5 --- /dev/null +++ b/apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import type { BubbleListProps } from '@ant-design/x'; +import type { Attachment } from '@ant-design/x/es/attachments'; +import type { MessageInfo } from '@ant-design/x-sdk'; +import { Tooltip } from 'antd'; +import type { AiAttachment, AiChatMessage, AiConversation } from './types'; + +export interface ConversationData extends AiConversation { + key: string; + label: string; +} + +export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped'; + +export function conversationStatusMeta(status: ConversationRunStatus): { + label: string; + color: string; +} { + if (status === 'running') return { label: '生成中', color: 'processing' }; + if (status === 'done') return { label: '已完成', color: 'success' }; + if (status === 'error') return { label: '失败', color: 'error' }; + return { label: '已停止', color: 'default' }; +} + +export function sortConversations(items: AiConversation[]): AiConversation[] { + return [...items].sort((a, b) => { + const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime(); + const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime(); + return bTime - aTime; + }); +} + +export function toConversationData(item: AiConversation): ConversationData { + return { ...item, key: String(item.id), label: item.title }; +} + +export function toUploadFile(attachment: AiAttachment): Attachment { + return { + uid: String(attachment.id), + name: attachment.name, + size: attachment.size, + status: + attachment.status === 'ready' + ? 'done' + : attachment.status === 'failed' + ? 'error' + : 'uploading', + url: attachment.url, + response: attachment, + description: attachment.error || undefined, + cardType: attachment.mimeType.startsWith('image/') ? 'image' : 'file', + }; +} + +export function emptyAssistant(): AiChatMessage { + return { + role: 'assistant', + content: '', + reasoningContent: '', + toolRuns: [], + attachments: [], + }; +} + +/** + * 当前会话内新发送的用户消息还没有服务端数字 ID(本地为 msg_N 临时 key), + * 但紧随其后的 AI 回答会携带 replyToMessageId,可据此反推用户消息 ID。 + */ +export function resolveUserMessageId( + info: MessageInfo, + all: MessageInfo[], +): number | null { + if (typeof info.message.id === 'number') return info.message.id; + const index = all.findIndex((item) => item.id === info.id); + if (index === -1) return null; + for (const item of all.slice(index + 1)) { + if (typeof item.message.replyToMessageId === 'number') { + return item.message.replyToMessageId; + } + } + return null; +} + +export interface HoverActionItem { + key: string; + title: string; + icon: React.ReactNode; + danger?: boolean; + onClick: () => void; +} + +/** Codex Desktop 风格:hover 消息时在气泡外显示的纯图标操作,不包裹 Button */ +export function MessageHoverActions({ items }: { items: HoverActionItem[] }) { + return ( +
+ {items.map((item) => ( + + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + item.onClick(); + } + }} + > + {item.icon} + + + ))} +
+ ); +} + +export const aiBubbleRoles: BubbleListProps['role'] = { + user: { placement: 'end', variant: 'filled', shape: 'corner' }, + assistant: { placement: 'start', variant: 'borderless' }, +}; diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx new file mode 100644 index 0000000..7ed21b6 --- /dev/null +++ b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { + CheckSquareOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PaperClipOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import Attachments from '@ant-design/x/es/attachments'; +import Conversations from '@ant-design/x/es/conversations'; +import Sender from '@ant-design/x/es/sender'; +import type { ConversationItemType } from '@ant-design/x'; +import type { AttachmentsProps } from '@ant-design/x/es/attachments'; +import { Button, Dropdown, Spin, Tooltip, Typography } from 'antd'; +import type { MenuProps } from 'antd'; +import type { AiSkill } from './types'; + +export interface AiChatSidebarProps { + className?: string; + conversationItems: ConversationItemType[]; + activeConversationKey?: string; + selectionMode: boolean; + selectedKeys: string[]; + loadingList: boolean; + conversationCount: number; + onActiveChange: (key: string) => void; + menu?: MenuProps | ((item: ConversationItemType) => MenuProps); + onStartNewConversation: () => void; + onSelectAll: () => void; + onInvertSelection: () => void; + onDeleteSelected: () => void; + onExitSelectionMode: () => void; + onEnterSelectionMode: () => void; +} + +export const AiChatSidebar: React.FC = ({ + className, + conversationItems, + activeConversationKey, + selectionMode, + selectedKeys, + loadingList, + conversationCount, + onActiveChange, + menu, + onStartNewConversation, + onSelectAll, + onInvertSelection, + onDeleteSelected, + onExitSelectionMode, + onEnterSelectionMode, +}) => { + return ( + + ); +}; + +export interface AiChatComposerProps { + conversationTitle: string; + input: string; + onChange: (value: string) => void; + isRequesting: boolean; + onSubmit: (value: string) => void; + onCancel: () => void; + uploadItems: AttachmentsProps['items']; + onCustomUpload: AttachmentsProps['customRequest']; + onRemoveAttachment: AttachmentsProps['onRemove']; + deepThinking: boolean; + onDeepThinkingChange: (value: boolean) => void; + lockedSkill?: AiSkill; + onClearSkill: () => void; + onToggleSidebar: () => void; + sidebarOpen: boolean; + skillMenu: MenuProps; +} + +export const AiChatComposer: React.FC = ({ + conversationTitle, + input, + onChange, + isRequesting, + onSubmit, + onCancel, + uploadItems, + onCustomUpload, + onRemoveAttachment, + deepThinking, + onDeepThinkingChange, + lockedSkill, + onClearSkill, + onToggleSidebar, + sidebarOpen, + skillMenu, +}) => { + return ( + <> +
+ + + +
+
+ { + // 中文输入法合成中的回车(确认候选词)不应触发发送。 + // 浏览器在 compositionend 后仍会派发 Enter keydown, + // 此时 Sender 内部的 composition 标记已失效,需用 + // KeyboardEvent.isComposing / keyCode 229 兜底。 + if (e.nativeEvent.isComposing || e.keyCode === 229) { + return false; + } + return undefined; + }} + autoSize={{ minRows: 1, maxRows: 6 }} + placeholder="询问学生、考勤、宿舍或账单数据" + skill={ + lockedSkill + ? { + title: lockedSkill.name, + value: lockedSkill.key, + closable: { onClose: onClearSkill }, + } + : undefined + } + header={ + (uploadItems ?? []).length > 0 && ( +
+ +
+ ) + } + footer={ +
+ + +
+ } + /> + + AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准 + +
+ + ); +}; diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index 427e9bf..44101e4 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -1,152 +1,71 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - CheckSquareOutlined, DeleteOutlined, EditOutlined, ArrowRightOutlined, LoadingOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, - PaperClipOutlined, - PlusOutlined, RobotOutlined, } from '@ant-design/icons'; +import Bubble from '@ant-design/x/es/bubble'; +import Prompts from '@ant-design/x/es/prompts'; +import Welcome from '@ant-design/x/es/welcome'; +import type { ConversationItemType } from '@ant-design/x'; +import { useXConversations } from '@ant-design/x-sdk'; import { - Attachments, - Bubble, - Conversations, - Prompts, - Sender, - SenderSwitch, - Welcome, -} from '@ant-design/x'; -import type { - BubbleItemType, - BubbleListProps, - ConversationItemType, - PromptsItemType, -} from '@ant-design/x'; -import type { Attachment } from '@ant-design/x/es/attachments'; -import { useXChat, useXConversations, type MessageInfo } from '@ant-design/x-sdk'; -import { - Button, + App, Checkbox, Drawer, - Dropdown, Grid, Input, - Modal, - Spin, - Tooltip, - Typography, } from 'antd'; -import type { MenuProps, UploadFile, UploadProps } from 'antd'; +import type { MenuProps } from 'antd'; import { message } from '../../ui/app-message'; -import { useSettingsStore } from '../../store/settings/settingsStore'; import { aiChatApi, conversationStreamUrl } from './api'; -import { AiMessageContent } from './AiMessageContent'; -import { mapHistoryMessage } from './message-mappers'; import { GongxueAiChatProvider } from './provider'; -import type { - AiAttachment, - AiChatInput, - AiChatMessage, - AiChatMessageStatus, - AiConversation, - AiFormSchema, - AiReviewSchema, - AiReviewSection, - AiReviewSectionType, - AiSkill, - AiSseChunk, -} from './types'; +import { ImportWizardModal } from '../ImportWizard/ImportWizardModal'; +import type { AiSkill } from './types'; +import { useAiChatMessageActions } from './useAiChatMessageActions'; +import { AiChatComposer, AiChatSidebar } from './AiChatDrawer.parts'; +import { + aiBubbleRoles, + conversationStatusMeta, + sortConversations, + toConversationData, + type ConversationData, + type ConversationRunStatus, +} from './AiChatDrawer.helpers'; import './style.css'; +export { + aiBubbleRoles, + conversationStatusMeta, + type ConversationData, + type ConversationRunStatus, +} from './AiChatDrawer.helpers'; + interface AiChatDrawerProps { open: boolean; onClose: () => void; onRequestingChange?: (working: boolean) => void; } -interface ConversationData extends AiConversation { - key: string; - label: string; -} - -export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped'; - -export function conversationStatusMeta(status: ConversationRunStatus): { - label: string; - color: string; -} { - if (status === 'running') return { label: '生成中', color: 'processing' }; - if (status === 'done') return { label: '已完成', color: 'success' }; - if (status === 'error') return { label: '失败', color: 'error' }; - return { label: '已停止', color: 'default' }; -} - -function sortConversations(items: AiConversation[]): AiConversation[] { - return [...items].sort((a, b) => { - const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime(); - const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime(); - return bTime - aTime; - }); -} - -function toConversationData(item: AiConversation): ConversationData { - return { ...item, key: String(item.id), label: item.title }; -} - -function toUploadFile(attachment: AiAttachment): Attachment { - return { - uid: String(attachment.id), - name: attachment.name, - size: attachment.size, - status: attachment.status === 'ready' ? 'done' : attachment.status === 'failed' ? 'error' : 'uploading', - url: attachment.url, - response: attachment, - description: attachment.error || undefined, - cardType: attachment.mimeType.startsWith('image/') ? 'image' : 'file', - }; -} - -function emptyAssistant(): AiChatMessage { - return { - role: 'assistant', - content: '', - reasoningContent: '', - toolRuns: [], - attachments: [], - }; -} - -export const aiBubbleRoles: BubbleListProps['role'] = { - user: { placement: 'end', variant: 'filled', shape: 'corner' }, - assistant: { placement: 'start', variant: 'borderless' }, -}; - const AiChatDrawer: React.FC = ({ open, onClose, onRequestingChange }) => { + const { modal } = App.useApp(); const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const [loadingList, setLoadingList] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(!isMobile); - const [input, setInput] = useState(''); + const effectiveSidebarOpen = isMobile ? false : sidebarOpen; const [skills, setSkills] = useState([]); - const [attachments, setAttachments] = useState([]); - const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking); - const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking); const [conversationStatus, setConversationStatus] = useState< Record >({}); + const [importWizardRunId, setImportWizardRunId] = useState(null); 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, @@ -160,15 +79,16 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const activeConversationKeyRef = useRef(activeConversationKey); const activeConversation = useMemo( - () => conversations.find((item) => item.key === activeConversationKey) as ConversationData | undefined, + () => + conversations.find((item) => item.key === activeConversationKey) as + | ConversationData + | undefined, [activeConversationKey, conversations], ); const activeId = activeConversation?.id ?? null; const lockedSkill = skills.find((skill) => skill.key === activeConversation?.lockedSkillKey); activeConversationKeyRef.current = activeConversationKey; - useEffect(() => setSidebarOpen(!isMobile), [isMobile]); - const refreshConversations = useCallback(async () => { const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData); setConversations(items); @@ -193,115 +113,53 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting [], ); - const provider = useMemo( - () => { - if (!activeId) return undefined; - const existing = providersRef.current.get(activeId); - if (existing) return existing; - const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => { - void refreshConversations(); - markConversationFinished(activeId, result); - }); - providersRef.current.set(activeId, created); - return created; - }, - [activeId, markConversationFinished, refreshConversations], - ); + const provider = useMemo(() => { + if (!activeId) return undefined; + const existing = providersRef.current.get(activeId); + if (existing) return existing; + const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => { + void refreshConversations(); + markConversationFinished(activeId, result); + }); + providersRef.current.set(activeId, created); + return created; + }, [activeId, markConversationFinished, refreshConversations]); - const { messages, onRequest, onReload, isRequesting, abort, setMessage, queueRequest } = useXChat< - AiChatMessage, - AiChatMessage, - AiChatInput, - AiSseChunk - >({ + const { + input, + setInput, + deepThinking, + setDeepThinking, + isRequesting, + messages, + stopRequest, + submit, + customUpload, + removeAttachment, + discardPendingAttachments, + uploadItems, + promptItems, + bubbleItems, + } = useAiChatMessageActions({ + activeConversation, + activeId, provider, - conversationKey: activeConversationKey || 'no-conversation', - defaultMessages: async () => { - if (!activeId) return []; - const page = await aiChatApi.listMessages(activeId); - return page.items.map(mapHistoryMessage); - }, - requestPlaceholder: emptyAssistant(), - requestFallback: ( - params: Partial, - { error, messageInfo }: { error: Error; messageInfo: MessageInfo }, - ) => ({ - ...(params.reloadMessage || messageInfo?.message || emptyAssistant()), - error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试', - cancelled: error.name === 'AbortError', - }), + requestAbortRef, + markConversationRunning, + addConversation, + setActiveConversationKey, + refreshConversations, + skills, + lockedSkill, + setImportWizardRunId, }); - useEffect(() => { - if (!provider) return; - provider.onExternalReview = (messageId, review) => { - setMessage(messageId, (info) => ({ - message: { - ...info.message, - reviews: (info.message.reviews ?? []).some((item) => item.id === review.id) - ? (info.message.reviews ?? []).map((item) => (item.id === review.id ? review : item)) - : [...(info.message.reviews ?? []), review], - }, - })); - }; - }, [provider, setMessage]); - - requestingRef.current = isRequesting; - abortRef.current = abort; - attachmentsRef.current = attachments; - + // isRequesting 由 @ant-design/x-sdk 的 useXChat 内部维护且没有完成回调, + // 这里把它视为外部 SDK 状态做订阅转发,是 Effect 的合理用法。 useEffect(() => { onRequestingChange?.(isRequesting); }, [isRequesting, onRequestingChange]); - const stopRequest = useCallback(() => { - if (requestingRef.current) abortRef.current(); - }, []); - - const requestWithStatus = useCallback( - (params: AiChatInput) => { - if (!activeId || !provider) return; - requestAbortRef.current.set(activeId, () => provider.request.abort()); - markConversationRunning(activeId); - onRequest(params); - }, - [activeId, markConversationRunning, onRequest, provider], - ); - - const reloadWithStatus = useCallback( - (messageInfo: MessageInfo) => { - if (!activeId || !provider || typeof messageInfo.message.id !== 'number') return; - requestAbortRef.current.set(activeId, () => provider.request.abort()); - markConversationRunning(activeId); - onReload(messageInfo.id, { - message: '', - attachmentIds: [], - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - regenerateMessageId: messageInfo.message.id, - reloadMessage: messageInfo.message, - }); - }, - [ - activeConversation?.lockedSkillKey, - activeId, - deepThinking, - markConversationRunning, - onReload, - provider, - ], - ); - - const discardPendingAttachments = useCallback(() => { - const pending = attachmentsRef.current; - attachmentsRef.current = []; - setAttachments([]); - for (const attachment of pending) { - void aiChatApi.deleteAttachment(attachment.id).catch(() => undefined); - } - }, []); - useEffect(() => { if (!open || loadedRef.current) return; let cancelled = false; @@ -322,10 +180,14 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting }; }, [open, setActiveConversationKey, setConversations]); - useEffect(() => { - discardPendingAttachments(); - if (isMobile) setSidebarOpen(false); - }, [activeConversationKey, discardPendingAttachments, isMobile]); + const switchConversation = useCallback( + (key: string) => { + discardPendingAttachments(); + if (isMobile) setSidebarOpen(false); + setActiveConversationKey(key); + }, + [discardPendingAttachments, isMobile, setActiveConversationKey], + ); useEffect( () => () => { @@ -338,17 +200,22 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting /** 新建对话(Codex 风格):先进入草稿态,发送第一条消息时才创建 session */ const startNewConversation = useCallback(() => { - setActiveConversationKey(''); - if (isMobile) setSidebarOpen(false); - }, [isMobile, setActiveConversationKey]); + switchConversation(''); + }, [switchConversation]); const renameConversation = useCallback( (conversation: ConversationData) => { let title = conversation.title; - Modal.confirm({ + modal.confirm({ title: '重命名会话', icon: , - content: (title = event.target.value)} />, + content: ( + (title = event.target.value)} + /> + ), okText: '保存', cancelText: '取消', onOk: async () => { @@ -383,7 +250,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const deleteConversation = useCallback( (conversation: ConversationData) => { - Modal.confirm({ + modal.confirm({ title: '删除会话', content: '该会话及全部历史消息将被永久删除。', okText: '删除', @@ -395,9 +262,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting removeConversation(conversation.key); const remaining = conversations.filter((item) => item.key !== conversation.key); if (!remaining.length) { - setActiveConversationKey(''); + switchConversation(''); } else if (conversation.id === activeId) { - setActiveConversationKey(remaining[0].key); + switchConversation(remaining[0].key); } }, }); @@ -405,9 +272,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting [ activeId, conversations, + switchConversation, removeConversation, removeConversationEntry, - setActiveConversationKey, ], ); @@ -443,7 +310,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting selectedKeys.includes(item.key), ) as ConversationData[]; if (!selected.length) return; - Modal.confirm({ + modal.confirm({ title: `删除选中的 ${selected.length} 个会话`, content: '选中的会话及全部历史消息将被永久删除,此操作不可恢复。', okText: '删除', @@ -457,7 +324,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting setConversationStatus({}); await aiChatApi.deleteAllConversations(); setConversations([]); - setActiveConversationKey(''); + switchConversation(''); } else { for (const item of selected) removeConversationEntry(item); const deletedKeys: string[] = []; @@ -477,9 +344,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const remaining = conversations.filter((item) => !deleted.has(item.key)); setConversations(remaining); if (!remaining.length) { - setActiveConversationKey(''); + switchConversation(''); } else if (activeId != null && !remaining.some((item) => item.id === activeId)) { - setActiveConversationKey(remaining[0].key); + switchConversation(remaining[0].key); } if (failedTitles.length) message.error(`删除失败:${failedTitles.join('、')}`); } @@ -493,7 +360,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting removeConversation, removeConversationEntry, selectedKeys, - setActiveConversationKey, + switchConversation, setConversations, ]); @@ -505,7 +372,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting ], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); - const conversation = conversations.find((entry) => entry.key === item.key) as ConversationData; + const conversation = conversations.find( + (entry) => entry.key === item.key, + ) as ConversationData; if (key === 'rename') renameConversation(conversation); if (key === 'delete') deleteConversation(conversation); }, @@ -521,252 +390,14 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting await aiChatApi.updateConversation(activeConversation.id, { lockedSkillKey: skillKey }), ); setConversation(activeConversation.key, updated); - } catch { + } catch (error) { + console.error('切换技能失败', error); message.error('切换技能失败'); } }, [activeConversation, setConversation], ); - const submit = useCallback( - (value: string) => { - const text = value.trim(); - if (!text || isRequesting) return; - const submittedAttachments = attachmentsRef.current; - attachmentsRef.current = []; - setAttachments([]); - setInput(''); - const params: AiChatInput = { - message: text, - attachmentIds: submittedAttachments.map((item) => item.id), - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - localAttachments: submittedAttachments, - }; - if (activeId != null) { - requestWithStatus(params); - return; - } - // 草稿态:先创建 session,再发送第一条消息 - void (async () => { - try { - const created = toConversationData(await aiChatApi.createConversation()); - addConversation(created, 'prepend'); - pendingDraftConversationIdRef.current = created.id; - markConversationRunning(created.id); - // 通过 XChat 的队列机制发送:等会话 key 切换并加载完成后再真正发出, - // 保证消息写入新会话的 store,界面能正常显示对话内容。 - queueRequest(created.key, params); - setActiveConversationKey(created.key); - } catch { - message.error('创建会话失败,请重试'); - attachmentsRef.current = submittedAttachments; - setAttachments(submittedAttachments); - setInput(text); - } - })(); - }, - [ - activeConversation?.lockedSkillKey, - activeId, - addConversation, - deepThinking, - isRequesting, - markConversationRunning, - queueRequest, - requestWithStatus, - setActiveConversationKey, - ], - ); - - // 草稿 session 创建完成、provider 就绪后注册中止句柄 - useEffect(() => { - if (activeId == null || !provider) return; - if (activeId !== pendingDraftConversationIdRef.current) return; - pendingDraftConversationIdRef.current = null; - requestAbortRef.current.set(activeId, () => provider.request.abort()); - }, [activeId, provider]); - - const reloadMessage = useCallback( - (messageInfo: MessageInfo) => { - reloadWithStatus(messageInfo); - }, - [reloadWithStatus], - ); - - const submitForm = useCallback( - (form: AiFormSchema, values: Record) => { - if (!activeId || isRequesting) return; - requestWithStatus({ - message: '表单提交', - attachmentIds: [], - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - formSubmission: { formId: form.id, values, formTitle: form.title }, - }); - }, - [activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus], - ); - - const submitReview = useCallback( - (reviewId: string, reviewTitle?: string) => { - if (!activeId || isRequesting) return; - requestWithStatus({ - message: '确认批量导入', - attachmentIds: [], - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - reviewSubmission: { reviewId, reviewTitle }, - }); - }, - [activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus], - ); - - const confirmReviewStep = useCallback( - async ( - messageId: number | undefined, - reviewId: string, - sectionKey: AiReviewSection['key'], - ): Promise => { - 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( - async (messageInfo: MessageInfo, feedback: 'like' | 'dislike' | null) => { - if (typeof messageInfo.message.id !== 'number') return; - try { - await aiChatApi.setFeedback(messageInfo.message.id, feedback); - setMessage(messageInfo.id, { - message: { ...messageInfo.message, feedback }, - }); - } catch { - message.error('提交反馈失败'); - } - }, - [setMessage], - ); - - const customUpload = useCallback>(async (options) => { - const file = options.file as File; - if (attachmentsRef.current.length >= 5) { - const error = new Error('每条消息最多添加 5 个附件'); - options.onError?.(error); - message.warning(error.message); - return; - } - try { - const uploaded = await aiChatApi.uploadAttachment(file); - setAttachments((items) => [...items, uploaded]); - options.onSuccess?.(uploaded, file); - } catch (error) { - options.onError?.(error instanceof Error ? error : new Error('附件上传失败')); - message.error('附件上传失败'); - } - }, []); - - const removeAttachment = useCallback(async (file: UploadFile) => { - const attachment = file.response; - if (!attachment) return true; - try { - await aiChatApi.deleteAttachment(attachment.id); - setAttachments((items) => items.filter((item) => item.id !== attachment.id)); - return true; - } catch { - message.error('删除附件失败'); - return false; - } - }, []); - - const uploadItems = useMemo(() => attachments.map(toUploadFile), [attachments]); - const promptItems = useMemo( - () => - (lockedSkill ? [lockedSkill] : skills) - .flatMap((skill) => skill.examples.slice(0, lockedSkill ? 4 : 1).map((example) => ({ skill, example }))) - .slice(0, 5) - .map(({ skill, example }) => ({ - key: `${skill.key}-${example}`, - label: example, - description: skill.name, - })), - [lockedSkill, skills], - ); - - const bubbleItems = useMemo( - () => - messages.map((info) => ({ - key: info.id, - role: info.message.role === 'assistant' ? 'assistant' : 'user', - status: info.status, - content: info.message, - contentRender: (content: AiChatMessage) => ( - reloadMessage(info) : undefined} - onFeedback={content.role === 'assistant' ? (feedback) => void updateFeedback(info, feedback) : undefined} - onSubmitForm={submitForm} - onSubmitReview={submitReview} - onConfirmReviewStep={confirmReviewStep} - onConfirmReviewGroup={confirmReviewGroup} - /> - ), - })), - [confirmReviewGroup, confirmReviewStep, messages, reloadMessage, submitForm, submitReview, updateFeedback], - ); - const conversationItems = useMemo( () => conversations.map((item) => { @@ -822,83 +453,61 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting return ( 恭学 AI 助手} + title={ + + + 恭学 AI 助手 + + } open={open} closeIcon={} onClose={onClose} - width={isMobile ? '100%' : 'min(1040px, 92vw)'} + size={isMobile ? '100%' : 'min(1040px, 92vw)'} destroyOnHidden={false} className="ai-chat-drawer" styles={{ body: { padding: 0, height: '100%' } }} >
- + { + if (selectionMode) toggleConversationSelection(key); + else switchConversation(key); + }} + menu={conversationMenu} + onStartNewConversation={startNewConversation} + onSelectAll={selectAllConversations} + onInvertSelection={invertConversationSelection} + onDeleteSelected={deleteSelectedConversations} + onExitSelectionMode={exitSelectionMode} + onEnterSelectionMode={enterSelectionMode} + />
-
- - - -
+ void setLockedSkill(null)} + onToggleSidebar={() => setSidebarOpen((value) => !value)} + sidebarOpen={effectiveSidebarOpen} + skillMenu={skillMenu} + conversationTitle={activeConversation?.title || 'AI 助手'} + />
{messages.length ? ( @@ -909,7 +518,10 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting variant="borderless" icon={} title="你好,我是恭学 AI 助手" - description={lockedSkill?.description || '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'} + description={ + lockedSkill?.description || + '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。' + } /> = ({ open, onClose, onRequesting )}
-
- void setLockedSkill(null) }, - } - : undefined - } - header={ - uploadItems.length > 0 && ( -
- -
- ) - } - footer={ -
- - -
- } + {importWizardRunId !== null && ( + setImportWizardRunId(null)} /> - - AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准 - -
+ )}
diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index ea4df02..d5b5346 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -1,39 +1,30 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { CheckCircleOutlined, CloseCircleOutlined, - CopyOutlined, - DislikeFilled, - DislikeOutlined, - LikeFilled, - LikeOutlined, LoadingOutlined, - ReloadOutlined, + TableOutlined, } from '@ant-design/icons'; -import { - Actions, - CodeHighlighter, - FileCard, - Mermaid, - Sources, - Think, - ThoughtChain, -} from '@ant-design/x'; +import FileCard from '@ant-design/x/es/file-card'; +import Sources from '@ant-design/x/es/sources'; +import Think from '@ant-design/x/es/think'; +import ThoughtChain from '@ant-design/x/es/thought-chain'; import type { ThoughtChainItemType } from '@ant-design/x'; -import XMarkdown from '@ant-design/x-markdown'; -import type { ComponentProps } from '@ant-design/x-markdown'; -import { Alert, Flex, Space, Typography } from 'antd'; +import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown'; +import { Alert, Button, Flex, Input, Space, Typography } from 'antd'; import { useUserStore } from '../../store/user/userStore'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import { LiteCodeHighlighter } from './LiteCodeHighlighter'; +import { LiteMermaid } from './LiteMermaid'; import type { AiAttachment, AiChatMessage, AiChatMessageStatus, AiChartSchema, AiFormSchema, - AiMessageFeedback, + AiImportWizard, AiReviewSection, AiReviewSchema, AiReviewSectionType, @@ -52,6 +43,7 @@ const toolLabels: Record = { render_form: '生成表单', render_review: '生成导入预览', render_chart: '生成图表', + start_import_wizard: '生成导入向导', create_student: '创建学生', search_exams: '查询考试', search_schedules: '查询课表', @@ -66,8 +58,8 @@ const markdownComponents = { code: ({ children, lang, block }: ComponentProps) => { const content = String(children ?? '').replace(/\n$/, ''); if (!block) return {content}; - if (lang === 'mermaid') return {content}; - return {content}; + if (lang === 'mermaid') return {content}; + return {content}; }, }; @@ -118,7 +110,8 @@ function ToolChain({ tools }: { tools: AiToolRun[] }) { key: tool.toolCallId, title: toolLabels[tool.toolName] || tool.toolName, description: tool.durationMs ? `${tool.durationMs}ms` : undefined, - content: tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'), + content: + tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'), status: running ? 'loading' : success ? 'success' : 'error', icon: running ? ( @@ -135,11 +128,51 @@ function ToolChain({ tools }: { tools: AiToolRun[] }) { return ; } +function EditUserContent({ + initial, + onConfirm, + onCancel, +}: { + initial: string; + onConfirm: (value: string) => void; + onCancel?: () => void; +}) { + const [draft, setDraft] = useState(initial); + return ( + + setDraft(event.target.value)} + autoSize={{ minRows: 2, maxRows: 8 }} + onKeyDown={(event) => { + // 中文输入法合成中的回车不应触发保存 + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + onConfirm(draft); + } else if (event.key === 'Escape') { + onCancel?.(); + } + }} + /> + + + + + + ); +} + export interface AiMessageContentProps { message: AiChatMessage; status?: AiChatMessageStatus; - onReload?: () => void; - onFeedback?: (feedback: AiMessageFeedback) => void; + editing?: boolean; + onEditConfirm?: (value: string) => void; + onEditCancel?: () => void; onSubmitForm?: (form: AiFormSchema, values: Record) => void; onSubmitReview?: (reviewId: string, reviewTitle?: string) => void; onConfirmReviewStep?: ( @@ -152,17 +185,20 @@ export interface AiMessageContentProps { reviewId: string, type: AiReviewSectionType, ) => AiReviewSchema | Promise | void; + onOpenImportWizard?: (runId: string) => void; } export const AiMessageContent: React.FC = ({ message, status, - onReload, - onFeedback, + editing, + onEditConfirm, + onEditCancel, onSubmitForm, onSubmitReview, onConfirmReviewStep, onConfirmReviewGroup, + onOpenImportWizard, }) => { const streaming = status === 'loading' || status === 'updating'; const formSubmission = message.metadata?.a2uiSubmit; @@ -199,8 +235,8 @@ export const AiMessageContent: React.FC = ({ ? String((reviewSubmission as Record).reviewTitle) : '批量导入'; return ( - - + + ); } @@ -210,64 +246,55 @@ export const AiMessageContent: React.FC = ({ ? String((formSubmission as Record).formTitle) : '表单'; return ( - - + + ); } return ( - - {attachmentCards.length > 0 && {attachmentCards}} -
{message.content}
+ + {attachmentCards.length > 0 && ( + + {attachmentCards} + + )} + {editing ? ( + onEditConfirm?.(value)} + onCancel={onEditCancel} + /> + ) : ( +
{message.content}
+ )}
); } - const actionItems = [ - { - key: 'copy', - label: '复制', - icon: , - onItemClick: () => void navigator.clipboard.writeText(message.content), - }, - ...(onReload - ? [{ key: 'reload', label: '重新生成', icon: , onItemClick: onReload }] - : []), - ...(onFeedback - ? [ - { - key: 'like', - label: '有帮助', - icon: message.feedback === 'like' ? : , - onItemClick: () => onFeedback(message.feedback === 'like' ? null : 'like'), - }, - { - key: 'dislike', - label: '没帮助', - icon: message.feedback === 'dislike' ? : , - onItemClick: () => onFeedback(message.feedback === 'dislike' ? null : 'dislike'), - }, - ] - : []), - ]; - return ( - - {streaming && !message.content && !message.reasoningContent && message.toolRuns.length === 0 && ( -
- -
- )} + + {streaming && + !message.content && + !message.reasoningContent && + message.toolRuns.length === 0 && ( +
+ +
+ )} {message.retrying && ( )} {message.reasoningContent && ( - + = ({ )} {message.toolRuns.length > 0 && } - {attachmentCards.length > 0 && {attachmentCards}} + {attachmentCards.length > 0 && ( + + {attachmentCards} + + )} + {(() => { + const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined; + if (!wizard || !onOpenImportWizard) return null; + return ( + + + + {wizard.fileName} + + + ); + })()} {message.content && ( = ({ {(message.charts ?? []).map((chart: AiChartSchema) => ( ))} - {message.error && } + {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 index 7d65542..fc9c78e 100644 --- a/apps/admin/src/components/AiChat/DynamicChart.tsx +++ b/apps/admin/src/components/AiChat/DynamicChart.tsx @@ -1,12 +1,14 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { XCard, registerCatalog } from '@ant-design/x-card'; -import type { XAgentCommand_v0_9 } from '@ant-design/x-card'; -import { Button, Tag, Tooltip, Typography } from 'antd'; +import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card'; +import { Button, Spin, Tag, Tooltip, Typography } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; import type { EChartsType } from 'echarts/core'; -import ReactECharts, { type EChartsOption } from '../../components/ECharts'; +import type { EChartsOption } from '../../components/ECharts'; import type { AiChartSchema } from './types'; +// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取 +const ReactECharts = lazy(() => import('../../components/ECharts')); + const CHART_CATALOG_ID = 'gongxue-chart-catalog'; registerCatalog({ @@ -41,117 +43,129 @@ const CHART_TYPE_LABELS: Record = { funnel: '漏斗图', }; -function buildOption(chart: AiChartSchema): EChartsOption { +function buildNameValueRows(chart: AiChartSchema): { name: string; value: number }[] { + const nameField = chart.columns[0]?.key ?? ''; + const valueField = chart.columns[1]?.key ?? ''; + return chart.rows.map((row) => ({ + name: String(row[nameField] ?? ''), + value: numberValue(row[valueField]), + })); +} + +function buildScatterOption(chart: AiChartSchema): EChartsOption { const columns = chart.columns; - if (chart.chartType === 'scatter') { - const nameField = columns[0]?.key ?? ''; - const xField = columns[1]?.key ?? ''; - const yField = columns[2]?.key ?? ''; - const data = chart.rows.map((row) => ({ - name: String(row[nameField] ?? ''), - value: [numberValue(row[xField]), numberValue(row[yField])], - })); - return { - tooltip: { - trigger: 'item', - formatter: (params: unknown) => { - const item = params as { name?: string; value?: number[] }; - const [x, y] = item.value ?? []; - return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`; - }, + const nameField = columns[0]?.key ?? ''; + const xField = columns[1]?.key ?? ''; + const yField = columns[2]?.key ?? ''; + const data = chart.rows.map((row) => ({ + name: String(row[nameField] ?? ''), + value: [numberValue(row[xField]), numberValue(row[yField])], + })); + return { + tooltip: { + trigger: 'item', + formatter: (params: unknown) => { + const item = params as { name?: string; value?: number[] }; + const [x, y] = item.value ?? []; + return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`; }, - grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true }, - xAxis: { type: 'value', name: columns[1]?.title }, - yAxis: { type: 'value', name: columns[2]?.title }, - series: [{ type: 'scatter', symbolSize: 10, data }], - }; - } - if (chart.chartType === 'radar') { - const seriesNameField = columns[0]?.key ?? ''; - const indicatorColumns = columns.slice(1); - const indicators = indicatorColumns.map((column) => { - const values = chart.rows.map((row) => numberValue(row[column.key])); - const max = Math.max(1, ...values); - return { name: column.title, max: Math.ceil(max * 1.1) }; - }); - const seriesData = chart.rows.map((row) => ({ - name: String(row[seriesNameField] ?? ''), - value: indicatorColumns.map((column) => numberValue(row[column.key])), - })); - return { - tooltip: { trigger: 'item' }, - legend: { bottom: 0, type: 'scroll' }, - radar: { indicator: indicators, radius: '65%' }, - series: [{ type: 'radar', data: seriesData }], - }; - } - if (chart.chartType === 'gauge') { - const nameField = columns[0]?.key ?? ''; - const valueField = columns[1]?.key ?? ''; - const maxField = columns[2]?.key; - const gauges = chart.rows.map((row) => ({ - name: String(row[nameField] ?? ''), - value: numberValue(row[valueField]), - max: maxField ? Math.max(1, numberValue(row[maxField])) : 100, - })); - return { - series: gauges.map((gauge, index) => ({ - type: 'gauge', - center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'], - radius: '75%', - min: 0, - max: gauge.max, - title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 }, - detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] }, - data: [{ value: gauge.value, name: gauge.name }], - })), - }; - } - if (chart.chartType === 'funnel') { - const nameField = columns[0]?.key ?? ''; - const valueField = columns[1]?.key ?? ''; - const data = chart.rows.map((row) => ({ - name: String(row[nameField] ?? ''), - value: numberValue(row[valueField]), - })); - return { - tooltip: { trigger: 'item', formatter: '{b}: {c}' }, - legend: { bottom: 0, type: 'scroll' }, - series: [ - { - type: 'funnel', - left: '10%', - top: 20, - bottom: 40, - width: '80%', - minSize: '20%', - label: { formatter: '{b}: {c}' }, - data, - }, - ], - }; - } - if (chart.chartType === 'pie') { - const nameField = columns[0]?.key ?? ''; - const valueField = columns[1]?.key ?? ''; - const data = chart.rows.map((row) => ({ - name: String(row[nameField] ?? ''), - value: numberValue(row[valueField]), - })); - return { - tooltip: { trigger: 'item' }, - legend: { bottom: 0, type: 'scroll' }, - series: [ - { - type: 'pie', - radius: ['35%', '68%'], - center: ['50%', '45%'], - data, - label: { formatter: '{b}: {c}' }, - }, - ], - }; - } + }, + grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true }, + xAxis: { type: 'value', name: columns[1]?.title }, + yAxis: { type: 'value', name: columns[2]?.title }, + series: [{ type: 'scatter', symbolSize: 10, data }], + }; +} + +function buildRadarOption(chart: AiChartSchema): EChartsOption { + const columns = chart.columns; + const seriesNameField = columns[0]?.key ?? ''; + const indicatorColumns = columns.slice(1); + const indicators = indicatorColumns.map((column) => { + const values = chart.rows.map((row) => numberValue(row[column.key])); + const max = Math.max(1, ...values); + return { name: column.title, max: Math.ceil(max * 1.1) }; + }); + const seriesData = chart.rows.map((row) => ({ + name: String(row[seriesNameField] ?? ''), + value: indicatorColumns.map((column) => numberValue(row[column.key])), + })); + return { + tooltip: { trigger: 'item' }, + legend: { bottom: 0, type: 'scroll' }, + radar: { indicator: indicators, radius: '65%' }, + series: [{ type: 'radar', data: seriesData }], + }; +} + +function buildGaugeOption(chart: AiChartSchema): EChartsOption { + const columns = chart.columns; + const nameField = columns[0]?.key ?? ''; + const valueField = columns[1]?.key ?? ''; + const maxField = columns[2]?.key; + const gauges = chart.rows.map((row) => ({ + name: String(row[nameField] ?? ''), + value: numberValue(row[valueField]), + max: maxField ? Math.max(1, numberValue(row[maxField])) : 100, + })); + return { + series: gauges.map((gauge, index) => ({ + type: 'gauge', + center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'], + radius: '75%', + min: 0, + max: gauge.max, + title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 }, + detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] }, + data: [{ value: gauge.value, name: gauge.name }], + })), + }; +} + +function buildNameValueOption(chart: AiChartSchema): EChartsOption { + const data = buildNameValueRows(chart); + return chart.chartType === 'funnel' + ? { + tooltip: { trigger: 'item', formatter: '{b}: {c}' }, + legend: { bottom: 0, type: 'scroll' }, + series: [ + { + type: 'funnel', + left: '10%', + top: 20, + bottom: 40, + width: '80%', + minSize: '20%', + label: { formatter: '{b}: {c}' }, + data, + }, + ], + } + : { + tooltip: { trigger: 'item' }, + legend: { bottom: 0, type: 'scroll' }, + series: [ + { + type: 'pie', + radius: ['35%', '68%'], + center: ['50%', '45%'], + data, + label: { formatter: '{b}: {c}' }, + }, + ], + }; +} + +function buildOption(chart: AiChartSchema): EChartsOption { + if (chart.chartType === 'scatter') return buildScatterOption(chart); + if (chart.chartType === 'radar') return buildRadarOption(chart); + if (chart.chartType === 'gauge') return buildGaugeOption(chart); + if (chart.chartType === 'funnel' || chart.chartType === 'pie') return buildNameValueOption(chart); + return buildCategoryOption(chart); +} + +function buildCategoryOption(chart: AiChartSchema): EChartsOption { + const columns = chart.columns; const categoryField = columns[0]?.key ?? ''; const categories = chart.rows.map((row) => String(row[categoryField] ?? '')); const series = columns.slice(1).map((column) => ({ @@ -223,11 +237,9 @@ const ChartPreview: React.FC = ({ chart }) => { - + }> + + ); }; @@ -291,5 +303,3 @@ export const DynamicChart: React.FC = ({ chart }) => { ); }; - -export default DynamicChart; diff --git a/apps/admin/src/components/AiChat/DynamicForm.tsx b/apps/admin/src/components/AiChat/DynamicForm.tsx index fd7411a..eadf163 100644 --- a/apps/admin/src/components/AiChat/DynamicForm.tsx +++ b/apps/admin/src/components/AiChat/DynamicForm.tsx @@ -1,7 +1,21 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { XCard, registerCatalog } from '@ant-design/x-card'; -import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card'; -import { Alert, Button, DatePicker, Flex, Form, Input, InputNumber, Select, Typography } from 'antd'; +import { + XCard, + registerCatalog, + type ActionPayload, + type XAgentCommand_v0_9, +} from '@ant-design/x-card'; +import { + Alert, + Button, + DatePicker, + Flex, + Form, + Input, + InputNumber, + Select, + Typography, +} from 'antd'; import dayjs from 'dayjs'; import type { AiFormField, AiFormSchema } from './types'; @@ -47,7 +61,11 @@ function normalizeValues( } interface FormPreviewProps { - form?: AiFormSchema; + form?: AiFormSchema & { + submitting?: boolean; + submitted?: boolean; + error?: string | null; + }; disabled?: boolean; onAction?: (name: string, context: Record) => void; } @@ -58,18 +76,14 @@ interface FormPreviewProps { * 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 submitting = Boolean(form?.submitting); const initialValues = useMemo( - () => Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])), + () => + Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])), [form?.fields], ); if (!form) return null; - const finished = Boolean(runtime.submitted) || form.status === 'submitted'; + const finished = Boolean(form.submitted) || form.status === 'submitted'; const handleFinish = (values: Record) => { onAction?.('form:submit', { values: normalizeValues(form.fields, values) }); @@ -124,17 +138,20 @@ const FormPreview: React.FC = ({ form, disabled, onAction }) = options={field.options} /> ) : field.type === 'date' ? ( - + ) : ( )} ))} - {runtime.error && ( + {form.error && ( )} @@ -235,5 +252,3 @@ export const DynamicForm: React.FC = ({ form, disabled, onSubm ); }; - -export default DynamicForm; diff --git a/apps/admin/src/components/AiChat/DynamicReview.tsx b/apps/admin/src/components/AiChat/DynamicReview.tsx index 1c303a5..fb4485c 100644 --- a/apps/admin/src/components/AiChat/DynamicReview.tsx +++ b/apps/admin/src/components/AiChat/DynamicReview.tsx @@ -1,15 +1,35 @@ import React, { useEffect, useRef, useState } from 'react'; -import { XCard, registerCatalog } from '@ant-design/x-card'; -import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card'; -import { Alert, Button, Flex, Popconfirm, Steps, Table, Tag, Typography } from 'antd'; -import type { TableProps } from 'antd'; -import type { - AiReviewRow, - AiReviewSchema, - AiReviewSection, - AiReviewSectionStatus, - AiReviewSectionType, -} from './types'; +import { + XCard, + registerCatalog, + type ActionPayload, + type XAgentCommand_v0_9, +} from '@ant-design/x-card'; +import { + Alert, + Button, + Flex, + Popconfirm, + Steps, + Table, + Tag, + Typography, + type TableProps, +} from 'antd'; +import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types'; +import { + GROUP_STATUS_LABELS, + SECTION_ORDER, + SECTION_STATUS_LABELS, + SECTION_TYPE_LABELS, + dependencyHint, + groupSections, + groupStatus, + sectionCount, + sectionResultText, + sectionStatus, + sectionType, +} from './reviewSection'; const REVIEW_CATALOG_ID = 'gongxue-review-catalog'; @@ -35,125 +55,6 @@ function surfaceId(reviewId: string): string { return `review-${reviewId}`; } -const SECTION_TYPE_LABELS: Record = { - 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) { @@ -188,7 +89,14 @@ function SectionTable({ section }: { section: AiReviewSection }) { } interface ReviewPreviewProps { - review?: AiReviewSchema; + review?: AiReviewSchema & { + submitting?: boolean; + activeKey?: string; + activeType?: AiReviewSectionType; + submittingKey?: string | null; + submittingGroup?: boolean; + error?: string | null; + }; disabled?: boolean; onAction?: (name: string, context: Record) => void; } @@ -197,27 +105,19 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio if (!review) return null; const submitted = review.status === 'submitted'; const expired = review.status === 'expired'; - const runtime = review as unknown as { - submitting?: boolean; - activeKey?: string; - activeType?: string; - submittingKey?: string | null; - submittingGroup?: boolean; - error?: string | null; - }; - const submitting = Boolean(runtime.submitting); - const submittingKey = runtime.submittingKey ?? null; - const submittingGroup = Boolean(runtime.submittingGroup); + const submitting = Boolean(review.submitting); + const submittingKey = review.submittingKey ?? null; + const submittingGroup = Boolean(review.submittingGroup); const sections = review.sections; const presentTypes = SECTION_ORDER.filter((type) => sections.some((section) => sectionType(section) === type), ); - const activeType = presentTypes.includes(runtime.activeType as AiReviewSectionType) - ? (runtime.activeType as AiReviewSectionType) + const activeType = presentTypes.includes(review.activeType as AiReviewSectionType) + ? (review.activeType as AiReviewSectionType) : presentTypes[0]; if (!activeType) return null; const activeSection = - sections.find((section) => section.key === runtime.activeKey) ?? + sections.find((section) => section.key === review.activeKey) ?? groupSections(sections, activeType)[0]; const activeStatus = activeSection ? sectionStatus(activeSection) : 'pending'; const dependency = @@ -289,7 +189,10 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio )} item.key === activeType))} + current={Math.max( + 0, + typeItems.findIndex((item) => item.key === activeType), + )} items={typeItems.map((item) => ({ key: item.key, title: item.title, @@ -309,16 +212,18 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio {SECTION_TYPE_LABELS[activeType]} · 共 {group.length} 张表 / {typeTotal} 行 - {GROUP_STATUS_LABELS[ - groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) - ]} + { + GROUP_STATUS_LABELS[ + groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) + ] + } {groupDep && ( = ({ review, disabled, onActio }) } > - )} {submitted && } - {runtime.error && ( + {review.error && ( )} @@ -525,6 +426,8 @@ export const DynamicReview: React.FC = ({ const [submittingGroup, setSubmittingGroup] = useState(false); const [activeKey, setActiveKey] = useState(undefined); const [activeType, setActiveType] = useState(undefined); + const activeTypeRef = useRef(activeType); + activeTypeRef.current = activeType; const [localReview, setLocalReview] = useState(review); const [error, setError] = useState(null); const commandsRef = useRef([]); @@ -537,7 +440,9 @@ export const DynamicReview: React.FC = ({ review.sections.some((section) => sectionType(section) === type), ); const preferredType = - activeType && types.includes(activeType) ? activeType : types[0]; + activeTypeRef.current && types.includes(activeTypeRef.current) + ? activeTypeRef.current + : types[0]; setActiveType(preferredType); setActiveKey((current) => current && @@ -547,7 +452,7 @@ export const DynamicReview: React.FC = ({ ? current : review.sections.find((section) => sectionType(section) === preferredType)?.key, ); - }, [activeType, review]); + }, [review]); useEffect(() => { const sid = surfaceId(localReview.id); @@ -593,7 +498,16 @@ export const DynamicReview: React.FC = ({ }, }); setCommands([...cmds]); - }, [activeKey, activeType, disabled, error, localReview, submitting, submittingGroup, submittingKey]); + }, [ + activeKey, + activeType, + disabled, + error, + localReview, + submitting, + submittingGroup, + submittingKey, + ]); const handleSubmit = async (reviewId: string) => { if (submitting) return; @@ -639,8 +553,7 @@ export const DynamicReview: React.FC = ({ const handleAction = (payload: ActionPayload) => { const context = payload.context ?? {}; if (payload.name === 'review:submit') { - const reviewId = - typeof context.reviewId === 'string' ? context.reviewId : localReview.id; + const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id; void handleSubmit(reviewId); return; } @@ -648,33 +561,27 @@ export const DynamicReview: React.FC = ({ const type = context.type as AiReviewSectionType | undefined; if (type && SECTION_ORDER.includes(type)) { setActiveType(type); - setActiveKey( - localReview.sections.find((section) => sectionType(section) === type)?.key, - ); + setActiveKey(localReview.sections.find((section) => sectionType(section) === type)?.key); } return; } if (payload.name === 'review:selectStep') { if (typeof context.sectionKey === 'string') { - const section = localReview.sections.find( - (item) => item.key === context.sectionKey, - ); + const section = localReview.sections.find((item) => item.key === context.sectionKey); setActiveKey(context.sectionKey); if (section) setActiveType(sectionType(section)); } return; } if (payload.name === 'review:confirmStep') { - const reviewId = - typeof context.reviewId === 'string' ? context.reviewId : localReview.id; + const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id; if (typeof context.sectionKey === 'string') { void handleConfirmStep(reviewId, context.sectionKey); } return; } if (payload.name === 'review:confirmGroup') { - const reviewId = - typeof context.reviewId === 'string' ? context.reviewId : localReview.id; + const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id; const type = context.type as AiReviewSectionType | undefined; if (type && SECTION_ORDER.includes(type)) { void handleConfirmGroup(reviewId, type); @@ -684,16 +591,10 @@ export const DynamicReview: React.FC = ({ return (
- + - {error && } + {error && }
); }; - -export default DynamicReview; diff --git a/apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx b/apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx new file mode 100644 index 0000000..a3e13b6 --- /dev/null +++ b/apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx @@ -0,0 +1,49 @@ +import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; +import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'; +import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; +import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; +import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'; +import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'; +import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql'; +import css from 'react-syntax-highlighter/dist/esm/languages/prism/css'; + +// 只注册 AI 对话里常用的语言,避免 @ant-design/x 的 CodeHighlighter +// 把所有 prism 语言都打进主包 +SyntaxHighlighter.registerLanguage('tsx', tsx); +SyntaxHighlighter.registerLanguage('typescript', typescript); +SyntaxHighlighter.registerLanguage('javascript', javascript); +SyntaxHighlighter.registerLanguage('json', json); +SyntaxHighlighter.registerLanguage('bash', bash); +SyntaxHighlighter.registerLanguage('shell', bash); +SyntaxHighlighter.registerLanguage('sql', sql); +SyntaxHighlighter.registerLanguage('css', css); + +const SUPPORTED_LANGUAGES = new Set([ + 'tsx', + 'typescript', + 'javascript', + 'json', + 'bash', + 'shell', + 'sql', + 'css', +]); + +interface LiteCodeHighlighterProps { + lang?: string; + children: string; +} + +export function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) { + const language = lang && SUPPORTED_LANGUAGES.has(lang) ? lang : undefined; + return ( + + {children} + + ); +} diff --git a/apps/admin/src/components/AiChat/LiteMermaid.tsx b/apps/admin/src/components/AiChat/LiteMermaid.tsx new file mode 100644 index 0000000..be2e32c --- /dev/null +++ b/apps/admin/src/components/AiChat/LiteMermaid.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react'; + +interface LiteMermaidProps { + children: string; +} + +/** + * 轻量 Mermaid 渲染:动态 import mermaid,只有出现 mermaid 代码块时才加载 + * mermaid 及其解析器/图布局依赖,避免随 AI 抽屉主包一起加载。 + */ +export function LiteMermaid({ children }: LiteMermaidProps) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const container = containerRef.current; + if (!container) return; + + void (async () => { + try { + const mermaid = (await import('mermaid')).default; + mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' }); + const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children); + if (!cancelled) { + const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); + container.replaceChildren(doc.documentElement); + setError(null); + } + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : '图表渲染失败'); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [children]); + + if (error) { + return ( +
{children}
+ ); + } + return
; +} diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index 68441e4..f336abc 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -3,7 +3,6 @@ import type { AiApiResponse, AiAttachment, AiConversation, - AiMessageFeedback, AiMessagePage, AiReviewSchema, AiReviewSection, @@ -15,8 +14,7 @@ const basePath = '/ai/chat/conversations'; export const aiChatApi = { listSkills: async () => (await api.get>('/ai/chat/skills')).data, - listConversations: async () => - (await api.get>(basePath)).data, + listConversations: async () => (await api.get>(basePath)).data, createConversation: async (input?: { title?: string; lockedSkillKey?: string | null }) => (await api.post>(basePath, input ?? {})).data, updateConversation: async ( @@ -26,6 +24,12 @@ export const aiChatApi = { deleteConversation: (id: number) => api.delete(`${basePath}/${id}`), deleteAllConversations: async () => (await api.delete<{ success: boolean; data: { deleted: number } }>(basePath)).data, + deleteMessage: async (conversationId: number, messageId: number) => + ( + await api.delete>( + `${basePath}/${conversationId}/messages/${messageId}`, + ) + ).data, uploadAttachment: async (file: File): Promise => { const form = new FormData(); form.append('file', file); @@ -37,17 +41,6 @@ export const aiChatApi = { ).data; }, deleteAttachment: (id: number) => api.delete(`/ai/chat/attachments/${id}`), - setFeedback: async ( - messageId: number, - feedback: AiMessageFeedback, - reason?: string, - ) => - ( - await api.patch>( - `/ai/chat/messages/${messageId}/feedback`, - { feedback, reason }, - ) - ).data, confirmReviewStep: async ( reviewId: string, sectionKey: AiReviewSection['key'], @@ -90,7 +83,3 @@ export const aiChatApi = { export function conversationStreamUrl(id: number): string { return `/api${basePath}/${id}/stream`; } - -export function regenerateStreamUrl(conversationId: number, messageId: number): string { - return `/api${basePath}/${conversationId}/messages/${messageId}/regenerate/stream`; -} 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 eb11697..0aa813a 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -11,7 +11,6 @@ describe('AI chat history mapper', () => { status: 'completed', errorCode: null, createdAt: '2026-07-23T00:00:00.000Z', - feedback: 'like', attachments: [ { id: 8, @@ -37,7 +36,6 @@ describe('AI chat history mapper', () => { expect(mapped.message.reasoningContent).toBe('思考'); expect(mapped.message.toolRuns[0].summary).toBe('共 4 间'); expect(mapped.message.attachments).toHaveLength(1); - expect(mapped.message.feedback).toBe('like'); }); it('maps failed and cancelled history to X SDK statuses', () => { diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts index 2ee555e..e3a1390 100644 --- a/apps/admin/src/components/AiChat/message-mappers.ts +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -65,8 +65,6 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo { expect(message.toolRuns[0]).toMatchObject({ status: 'success', summary: '找到 1 条记录' }); }); - it('tracks processed attachments and final feedback state', () => { + it('tracks processed attachments and final message state', () => { let message = reduceAiSseMessage(undefined, { event: 'attachment.processed', data: JSON.stringify({ @@ -66,13 +66,11 @@ describe('AI chat SSE message reducer', () => { id: 12, content: '完成', reasoningContent: null, - feedback: 'like', attachments: message.attachments, }, }), }); expect(message.attachments).toHaveLength(1); - expect(message.feedback).toBe('like'); }); it('uses final content and records cancellation and errors', () => { diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index 3467a60..ad93b6d 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -35,6 +35,7 @@ interface AiSsePayload { form?: AiFormSchema; review?: AiReviewSchema; chart?: AiChartSchema; + wizard?: unknown; retry?: AiModelRetryInfo; message?: | string @@ -46,8 +47,6 @@ interface AiSsePayload { toolRuns?: AiToolRun[]; attachments?: AiAttachment[]; replyToMessageId?: number | null; - feedback?: 'like' | 'dislike' | null; - feedbackReason?: string | null; metadata?: Record | null; }; error?: string; @@ -79,29 +78,10 @@ function mergeForms( return next; } -function mergeReviews( - current: AiReviewSchema[] | undefined, - incoming: AiReviewSchema | AiReviewSchema[] | undefined, -): AiReviewSchema[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (!item || typeof item !== 'object') continue; - const index = next.findIndex((existing) => existing.id === item.id); - if (index === -1) { - next.push(item); - } else { - next[index] = item; - } - } - return next; -} - -function mergeCharts( - current: AiChartSchema[] | undefined, - incoming: AiChartSchema | AiChartSchema[] | undefined, -): AiChartSchema[] { +function mergeById( + current: T[] | undefined, + incoming: T | T[] | undefined, +): T[] { const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; if (!items.length) return current ?? []; const next = [...(current ?? [])]; @@ -165,6 +145,28 @@ function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRu })); } +function applyMessagePayload( + message: AiChatMessage, + nested: AiSsePayload['message'], + payload: AiSsePayload, +): void { + if (typeof nested !== 'object' || nested === null) return; + message.forms = mergeForms( + message.forms, + (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, + ); + message.reviews = mergeById( + message.reviews, + (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, + ); + message.charts = mergeById( + message.charts, + (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, + ); + message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId; + message.metadata = nested.metadata ?? message.metadata; +} + export function reduceAiSseMessage( originMessage: AiChatMessage | undefined, chunk?: AiSseChunk, @@ -179,22 +181,7 @@ export function reduceAiSseMessage( message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - message.forms = mergeForms( - message.forms, - (nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, - ); - message.reviews = mergeReviews( - message.reviews, - (nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, - ); - message.charts = mergeCharts( - message.charts, - (nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, - ); - message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId; - message.feedback = nested?.feedback ?? message.feedback; - message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason; - message.metadata = nested?.metadata ?? message.metadata; + applyMessagePayload(message, nested, payload); } else if (event === 'reasoning.delta') { message.retrying = null; message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; @@ -206,9 +193,11 @@ export function reduceAiSseMessage( } else if (event === 'ui.form' && payload.form) { message.forms = mergeForms(message.forms, payload.form); } else if (event === 'ui.review' && payload.review) { - message.reviews = mergeReviews(message.reviews, payload.review); + message.reviews = mergeById(message.reviews, payload.review); } else if (event === 'ui.chart' && payload.chart) { - message.charts = mergeCharts(message.charts, payload.chart); + message.charts = mergeById(message.charts, payload.chart); + } else if (event === 'ui.import_wizard' && payload.wizard) { + message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; } else if (event === 'tool.started') { message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); } else if (event === 'tool.completed') { @@ -227,22 +216,7 @@ export function reduceAiSseMessage( nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - message.forms = mergeForms( - message.forms, - (nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, - ); - message.reviews = mergeReviews( - message.reviews, - (nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, - ); - message.charts = mergeCharts( - message.charts, - (nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, - ); - message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId; - message.feedback = nested?.feedback ?? message.feedback; - message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason; - message.metadata = nested?.metadata ?? message.metadata; + applyMessagePayload(message, nested, payload); message.retrying = null; } else if (event === 'message.cancelled') { message.id = payload.messageId ?? message.id; @@ -280,6 +254,16 @@ export async function authenticatedFetch( reasoningEffort: body.reasoningEffort, }), }; + } else if (body.editMessageId) { + requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.editMessageId}/edit/stream`; + requestInit = { + ...init, + body: JSON.stringify({ + content: body.message, + clientRequestId: body.clientRequestId, + reasoningEffort: body.reasoningEffort, + }), + }; } else if (body.formSubmission) { requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/forms/${body.formSubmission.formId}/submit/stream`; requestInit = { @@ -304,6 +288,7 @@ export async function authenticatedFetch( localAttachments: _localAttachments, reloadMessage: _reloadMessage, regenerateMessageId: _regenerateMessageId, + editMessageId: _editMessageId, formSubmission: _formSubmission, reviewSubmission: _reviewSubmission, ...payload @@ -331,10 +316,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider< /** Routes events that target another (already streamed) message. */ onExternalReview?: (messageId: number, review: AiReviewSchema) => void; - constructor( - url: string, - onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void, - ) { + constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) { super({ request: XRequest(url, { manual: true, @@ -369,11 +351,16 @@ export class GongxueAiChatProvider extends AbstractChatProvider< formSubmission: requestParams.formSubmission, reviewSubmission: requestParams.reviewSubmission, regenerateMessageId: requestParams.regenerateMessageId, + editMessageId: requestParams.editMessageId, reloadMessage: requestParams.reloadMessage, }; } - transformLocalMessage(requestParams: Partial): AiChatMessage { + transformLocalMessage(requestParams: Partial): AiChatMessage | AiChatMessage[] { + if (requestParams.editMessageId) { + // 编辑消息不需要新增用户气泡,store 里已原位更新原消息。 + return []; + } if (requestParams.formSubmission) { return { role: 'user', diff --git a/apps/admin/src/components/AiChat/reviewSection.ts b/apps/admin/src/components/AiChat/reviewSection.ts new file mode 100644 index 0000000..b50290e --- /dev/null +++ b/apps/admin/src/components/AiChat/reviewSection.ts @@ -0,0 +1,115 @@ +import type { AiReviewSection, AiReviewSectionStatus, AiReviewSectionType } from './types'; + +export const SECTION_TYPE_LABELS: Record = { + students: '学生', + rooms: '宿舍', + transfers: '换宿', + checkins: '入住记录', +}; + +export const SECTION_ORDER: AiReviewSectionType[] = ['students', 'rooms', 'transfers', 'checkins']; + +const SECTION_DEPENDENCIES: Record = { + students: [], + rooms: [], + transfers: ['students', 'rooms'], + checkins: [], +}; + +export 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'; +} + +export function sectionCount(section: AiReviewSection): number { + return section.rows.length; +} + +export function sectionStatus(section: AiReviewSection): AiReviewSectionStatus { + return section.status ?? 'pending'; +} + +export function sectionResultText(section: AiReviewSection): string { + if (!section.resultSummary) return ''; + try { + const parsed = JSON.parse(section.resultSummary) as { message?: unknown }; + if (typeof parsed.message === 'string') return parsed.message; + } catch { + // Older data may store a plain text summary. + } + return section.resultSummary; +} + +export const SECTION_STATUS_LABELS: Record = { + pending: '待确认', + submitted: '已导入', + failed: '失败', + skipped: '已跳过', +}; + +export type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing'; + +export const GROUP_STATUS_LABELS: Record = { + pending: '待确认', + partial: '部分完成', + submitted: '已导入', + failed: '失败', + importing: '导入中', +}; + +export function groupSections( + sections: AiReviewSection[], + type: AiReviewSectionType, +): AiReviewSection[] { + return sections.filter((section) => sectionType(section) === type); +} + +export function groupStatus( + sections: AiReviewSection[], + type: AiReviewSectionType, + submittingKey: string | null, + submittingGroup: boolean, + activeType?: AiReviewSectionType, +): GroupStatus { + const items = groupSections(sections, type); + if (items.length === 0) return 'pending'; + if ( + (submittingGroup && type === activeType) || + items.some((item) => submittingKey === item.key) + ) { + return 'importing'; + } + if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed'; + if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted'; + return 'partial'; +} + +export function dependencyHint( + sections: AiReviewSection[], + type: AiReviewSectionType, +): { step: number; title: string } | null { + for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) { + const matches = groupSections(sections, dependencyType); + if (matches.length === 0) { + return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] }; + } + for (const section of matches) { + if (sectionStatus(section) !== 'submitted') { + return { step: sections.indexOf(section), title: section.title }; + } + } + } + return null; +} diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css index f79f073..14a1e44 100644 --- a/apps/admin/src/components/AiChat/style.css +++ b/apps/admin/src/components/AiChat/style.css @@ -207,10 +207,68 @@ padding: 20px clamp(16px, 4vw, 48px); } +.ai-chat-messages .ant-bubble { + position: relative; +} + .ai-chat-messages .ant-bubble-content { max-width: min(100%, 680px); } +.ai-chat-messages .ant-bubble-extra { + position: absolute; + top: 2px; + right: 10px; + z-index: 2; +} + +.ai-chat-hover-actions { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + background: rgba(255, 255, 255, 0.94); + border: 1px solid #eceef2; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07); + opacity: 0; + transform: translateY(-3px); + transition: + opacity 0.15s ease, + transform 0.15s ease; + pointer-events: none; +} + +.ai-chat-messages .ant-bubble:hover .ai-chat-hover-actions, +.ai-chat-hover-actions:focus-within { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.ai-chat-hover-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + color: #5f6672; + font-size: 14px; + cursor: pointer; + user-select: none; +} + +.ai-chat-hover-action:hover { + background: #f0f2f5; + color: #1f2329; +} + +.ai-chat-hover-action.is-danger:hover { + background: #fff1f0; + color: #cf1322; +} + .ai-chat-user-text { max-width: 100%; overflow-wrap: anywhere; @@ -221,6 +279,10 @@ max-width: 100%; } +.ai-chat-user-edit { + width: min(520px, 100%); +} + .ai-chat-answer { width: 100%; min-width: 0; diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index b46d404..a0964b1 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -98,6 +98,23 @@ export interface AiChartSchema { rows: AiReviewRow[]; } +export interface AiImportWizard { + runId: string; + fileName: string; + sheets: Array<{ + name: string; + suggestedStepKey?: AiReviewSectionType | null; + headers: string[]; + rowCount: number; + }>; + steps: Array<{ + stepKey: AiReviewSectionType; + label: string; + sheets: string[]; + status: string; + }>; +} + export type AiToolRunStatus = | 'running' | 'success' @@ -126,7 +143,6 @@ export interface AiModelRetryInfo { } export type AiMessageRole = 'user' | 'assistant'; -export type AiMessageFeedback = 'like' | 'dislike' | null; export interface AiChatMessage { id?: number | string; @@ -139,8 +155,6 @@ export interface AiChatMessage { reviews?: AiReviewSchema[]; charts?: AiChartSchema[]; replyToMessageId?: number | null; - feedback?: AiMessageFeedback; - feedbackReason?: string | null; metadata?: Record | null; retrying?: AiModelRetryInfo | null; error?: string; @@ -155,8 +169,6 @@ export interface AiMessageRecord { status: 'pending' | 'completed' | 'failed' | 'cancelled'; errorCode: string | null; replyToMessageId?: number | null; - feedback?: AiMessageFeedback; - feedbackReason?: string | null; metadata?: Record | null; attachments?: AiAttachment[]; createdAt: string; @@ -176,6 +188,7 @@ export interface AiChatInput { skillKey: string | null; clientRequestId: string; reasoningEffort?: string | null; + editMessageId?: number; localAttachments?: AiAttachment[]; formSubmission?: { formId: string; diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx new file mode 100644 index 0000000..274633c --- /dev/null +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -0,0 +1,574 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'; +import { CopyOutlined, DeleteOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons'; +import type { BubbleItemType, PromptsItemType } from '@ant-design/x'; +import { useXChat, type MessageInfo } from '@ant-design/x-sdk'; +import { App } from 'antd'; +import type { UploadFile, UploadProps } from 'antd'; +import { message } from '../../ui/app-message'; +import { useSettingsStore } from '../../store/settings/settingsStore'; +import { aiChatApi } from './api'; +import { AiMessageContent } from './AiMessageContent'; +import { mapHistoryMessage } from './message-mappers'; +import { GongxueAiChatProvider } from './provider'; +import { + emptyAssistant, + MessageHoverActions, + resolveUserMessageId, + toConversationData, + toUploadFile, + type ConversationData, +} from './AiChatDrawer.helpers'; +import type { + AiAttachment, + AiChatInput, + AiChatMessage, + AiChatMessageStatus, + AiFormSchema, + AiReviewSchema, + AiReviewSection, + AiReviewSectionType, + AiSkill, + AiSseChunk, +} from './types'; + +interface UseAiChatMessageActionsParams { + activeConversation: ConversationData | undefined; + activeId: number | null; + provider: GongxueAiChatProvider | undefined; + requestAbortRef: MutableRefObject void>>; + markConversationRunning: (conversationId: number) => void; + addConversation: (conversation: ConversationData, placement?: 'prepend' | 'append') => boolean; + setActiveConversationKey: (key: string) => boolean; + refreshConversations: () => Promise; + skills: AiSkill[]; + lockedSkill: AiSkill | undefined; + setImportWizardRunId: (runId: string | null) => void; +} + +export function useAiChatMessageActions({ + activeConversation, + activeId, + provider, + requestAbortRef, + markConversationRunning, + addConversation, + setActiveConversationKey, + refreshConversations, + skills, + lockedSkill, + setImportWizardRunId, +}: UseAiChatMessageActionsParams) { + const { modal } = App.useApp(); + const [input, setInput] = useState(''); + const [attachments, setAttachments] = useState([]); + const [editingMessageId, setEditingMessageId] = useState(null); + const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking); + const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking); + const requestingRef = useRef(false); + const abortRef = useRef<() => void>(() => undefined); + const attachmentsRef = useRef([]); + const pendingDraftConversationIdRef = useRef(null); + const messagesRef = useRef[]>([]); + + const { + messages, + onRequest, + onReload, + isRequesting, + abort, + setMessage, + removeMessage, + queueRequest, + } = useXChat({ + provider, + conversationKey: activeConversation?.key || 'no-conversation', + defaultMessages: async () => { + if (!activeId) return []; + const page = await aiChatApi.listMessages(activeId); + return page.items.map(mapHistoryMessage); + }, + requestPlaceholder: emptyAssistant(), + requestFallback: ( + params: Partial, + { error, messageInfo }: { error: Error; messageInfo: MessageInfo }, + ) => ({ + ...(params.reloadMessage || messageInfo?.message || emptyAssistant()), + error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试', + cancelled: error.name === 'AbortError', + }), + }); + + useEffect(() => { + if (!provider) return; + provider.onExternalReview = (messageId, review) => { + setMessage(messageId, (info) => ({ + message: { + ...info.message, + reviews: (info.message.reviews ?? []).some((item) => item.id === review.id) + ? (info.message.reviews ?? []).map((item) => (item.id === review.id ? review : item)) + : [...(info.message.reviews ?? []), review], + }, + })); + }; + }, [provider, setMessage]); + + requestingRef.current = isRequesting; + abortRef.current = abort; + attachmentsRef.current = attachments; + messagesRef.current = messages; + + const stopRequest = useCallback(() => { + if (requestingRef.current) abortRef.current(); + }, []); + + const requestWithStatus = useCallback( + (params: AiChatInput) => { + if (!activeId || !provider) return; + requestAbortRef.current.set(activeId, () => provider.request.abort()); + markConversationRunning(activeId); + onRequest(params); + }, + [activeId, markConversationRunning, onRequest, provider, requestAbortRef], + ); + + const reloadWithStatus = useCallback( + (messageInfo: MessageInfo) => { + if (!activeId || !provider || typeof messageInfo.message.id !== 'number') return; + requestAbortRef.current.set(activeId, () => provider.request.abort()); + markConversationRunning(activeId); + onReload(messageInfo.id, { + message: '', + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + regenerateMessageId: messageInfo.message.id, + reloadMessage: messageInfo.message, + }); + }, + [ + activeConversation?.lockedSkillKey, + activeId, + deepThinking, + markConversationRunning, + onReload, + provider, + requestAbortRef, + ], + ); + + const discardPendingAttachments = useCallback(() => { + const pending = attachmentsRef.current; + attachmentsRef.current = []; + setAttachments([]); + for (const attachment of pending) { + void aiChatApi.deleteAttachment(attachment.id).catch(() => undefined); + } + }, []); + + const submit = useCallback( + (value: string) => { + const text = value.trim(); + if (!text || isRequesting) return; + const submittedAttachments = attachmentsRef.current; + attachmentsRef.current = []; + setAttachments([]); + setInput(''); + const params: AiChatInput = { + message: text, + attachmentIds: submittedAttachments.map((item) => item.id), + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + localAttachments: submittedAttachments, + }; + if (activeId != null) { + requestWithStatus(params); + return; + } + // 草稿态:先创建 session,再发送第一条消息 + void (async () => { + try { + const created = toConversationData(await aiChatApi.createConversation()); + addConversation(created, 'prepend'); + pendingDraftConversationIdRef.current = created.id; + markConversationRunning(created.id); + // 通过 XChat 的队列机制发送:等会话 key 切换并加载完成后再真正发出, + // 保证消息写入新会话的 store,界面能正常显示对话内容。 + queueRequest(created.key, params); + setActiveConversationKey(created.key); + } catch { + message.error('创建会话失败,请重试'); + attachmentsRef.current = submittedAttachments; + setAttachments(submittedAttachments); + setInput(text); + } + })(); + }, + [ + activeConversation?.lockedSkillKey, + activeId, + addConversation, + deepThinking, + isRequesting, + markConversationRunning, + queueRequest, + requestWithStatus, + setActiveConversationKey, + ], + ); + + // 草稿 session 创建完成、provider 就绪后注册中止句柄 + useEffect(() => { + if (activeId == null || !provider) return; + if (activeId !== pendingDraftConversationIdRef.current) return; + pendingDraftConversationIdRef.current = null; + requestAbortRef.current.set(activeId, () => provider.request.abort()); + }, [activeId, provider, requestAbortRef]); + + const reloadMessage = useCallback( + (messageInfo: MessageInfo) => { + reloadWithStatus(messageInfo); + }, + [reloadWithStatus], + ); + + const copyMessage = useCallback((message: AiChatMessage) => { + if (!message.content) return; + void navigator.clipboard.writeText(message.content); + }, []); + + const confirmDeleteMessage = useCallback( + (messageInfo: MessageInfo) => { + if (!activeId || isRequesting) return; + const messageId = resolveUserMessageId(messageInfo, messagesRef.current); + if (messageId == null) return; + const scopeLabel = + messageInfo.message.role === 'user' ? '这条消息及其 AI 回答' : '这条 AI 回答'; + modal.confirm({ + title: '删除消息', + content: `将删除${scopeLabel},此操作不可恢复。`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + const result = await aiChatApi.deleteMessage(activeId, messageId); + const storeIds = new Map(); + for (const item of messagesRef.current) { + if (typeof item.message.id === 'number') { + storeIds.set(item.message.id, item.id); + } + } + // 当前会话内新发送的用户消息没有服务端 ID,但可映射到本地 msg_N key + storeIds.set(messageId, messageInfo.id); + for (const id of result.deletedIds) removeMessage(storeIds.get(id) ?? id); + void refreshConversations(); + } catch (error) { + console.error('删除消息失败', error); + message.error('删除消息失败'); + } + }, + }); + }, + [activeId, isRequesting, refreshConversations, removeMessage], + ); + + const confirmEditMessage = useCallback( + (messageInfo: MessageInfo, value: string) => { + if (!activeId) return; + const content = value.trim(); + if (!content) { + message.warning('消息内容不能为空'); + return; + } + const messageId = resolveUserMessageId(messageInfo, messagesRef.current); + if (messageId == null) { + message.warning('消息尚未同步,请稍后重试'); + return; + } + setEditingMessageId(null); + if (content === messageInfo.message.content) return; + + setMessage(messageInfo.id, (info) => ({ + message: { + ...info.message, + content, + metadata: { ...info.message.metadata, edited: true }, + }, + })); + const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id); + if (index >= 0) { + for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id); + } + requestWithStatus({ + message: content, + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + editMessageId: messageId, + }); + }, + [ + activeConversation?.lockedSkillKey, + activeId, + deepThinking, + removeMessage, + requestWithStatus, + setMessage, + ], + ); + + const submitForm = useCallback( + (form: AiFormSchema, values: Record) => { + if (!activeId || isRequesting) return; + requestWithStatus({ + message: '表单提交', + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + formSubmission: { formId: form.id, values, formTitle: form.title }, + }); + }, + [activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus], + ); + + const submitReview = useCallback( + (reviewId: string, reviewTitle?: string) => { + if (!activeId || isRequesting) return; + requestWithStatus({ + message: '确认批量导入', + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + reviewSubmission: { reviewId, reviewTitle }, + }); + }, + [activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus], + ); + + const confirmReviewStep = useCallback( + async ( + messageId: number | undefined, + reviewId: string, + sectionKey: AiReviewSection['key'], + ): Promise => { + 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 customUpload = useCallback>(async (options) => { + const file = options.file as File; + if (attachmentsRef.current.length >= 5) { + const error = new Error('每条消息最多添加 5 个附件'); + options.onError?.(error); + message.warning(error.message); + return; + } + try { + const uploaded = await aiChatApi.uploadAttachment(file); + setAttachments((items) => [...items, uploaded]); + options.onSuccess?.(uploaded, file); + } catch (error) { + options.onError?.(error instanceof Error ? error : new Error('附件上传失败')); + message.error('附件上传失败'); + } + }, []); + + const removeAttachment = useCallback(async (file: UploadFile) => { + const attachment = file.response; + if (!attachment) return true; + try { + await aiChatApi.deleteAttachment(attachment.id); + setAttachments((items) => items.filter((item) => item.id !== attachment.id)); + return true; + } catch { + message.error('删除附件失败'); + return false; + } + }, []); + + const uploadItems = useMemo(() => attachments.map(toUploadFile), [attachments]); + const promptItems = useMemo( + () => + (lockedSkill ? [lockedSkill] : skills) + .flatMap((skill) => + skill.examples.slice(0, lockedSkill ? 4 : 1).map((example) => ({ skill, example })), + ) + .slice(0, 5) + .map(({ skill, example }) => ({ + key: `${skill.key}-${example}`, + label: example, + description: skill.name, + })), + [lockedSkill, skills], + ); + + const bubbleItems = useMemo( + () => + messages.map((info) => ({ + key: info.id, + role: info.message.role === 'assistant' ? 'assistant' : 'user', + status: info.status, + content: info.message, + extra: + info.status !== 'loading' && info.status !== 'updating' && !isRequesting ? ( + info.message.role === 'user' ? ( + editingMessageId === info.id ? undefined : ( + , + onClick: () => copyMessage(info.message), + }, + { + key: 'edit', + title: '编辑', + icon: , + onClick: () => setEditingMessageId(info.id), + }, + { + key: 'delete', + title: '删除', + icon: , + danger: true, + onClick: () => void confirmDeleteMessage(info), + }, + ]} + /> + ) + ) : ( + , + onClick: () => copyMessage(info.message), + }, + { + key: 'reload', + title: '重新生成', + icon: , + onClick: () => reloadMessage(info), + }, + ]} + /> + ) + ) : undefined, + contentRender: (content: AiChatMessage) => ( + confirmEditMessage(info, value) : undefined + } + onEditCancel={content.role === 'user' ? () => setEditingMessageId(null) : undefined} + onSubmitForm={submitForm} + onSubmitReview={submitReview} + onConfirmReviewStep={confirmReviewStep} + onConfirmReviewGroup={confirmReviewGroup} + onOpenImportWizard={setImportWizardRunId} + /> + ), + })), + [ + copyMessage, + confirmDeleteMessage, + confirmEditMessage, + confirmReviewGroup, + confirmReviewStep, + editingMessageId, + isRequesting, + messages, + reloadMessage, + setImportWizardRunId, + submitForm, + submitReview, + ], + ); + + return { + input, + setInput, + attachments, + setAttachments, + editingMessageId, + setEditingMessageId, + deepThinking, + setDeepThinking, + isRequesting, + messages, + stopRequest, + submit, + reloadMessage, + copyMessage, + confirmDeleteMessage, + confirmEditMessage, + submitForm, + submitReview, + confirmReviewStep, + confirmReviewGroup, + customUpload, + removeAttachment, + discardPendingAttachments, + uploadItems, + promptItems, + bubbleItems, + }; +} diff --git a/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx b/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx new file mode 100644 index 0000000..cf802f4 --- /dev/null +++ b/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx @@ -0,0 +1,415 @@ +import React from 'react'; +import { + ApiOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + CloudServerOutlined, + ReloadOutlined, + RobotOutlined, + SafetyOutlined, + SaveOutlined, + WarningOutlined, +} from '@ant-design/icons'; +import { + Alert, + AutoComplete, + Button, + Card, + Descriptions, + Form, + Input, + InputNumber, + Select, + Space, + Switch, + Tag, + Typography, +} from 'antd'; +import type { AiProvider } from './helpers'; +import { + PROVIDER_OPTIONS, + PROVIDER_DEFAULTS, + formatDateTime, + sourceColor, + sourceLabel, +} from './helpers'; +import styles from './index.module.css'; + +export interface AiConfigData { + id: number; + provider: AiProvider; + baseUrl: string; + hasApiKey: boolean; + hasDatabaseKey: boolean; + maskedApiKey: string | null; + keySource: 'database' | 'environment' | 'none'; + defaultModel: string | null; + enabled: boolean; + supportsVision: boolean; + timeoutMs: number; + reasoningEffort: string | null; + verified: boolean; + lastTestedAt: string | null; + lastTestLatencyMs: number | null; + createdAt: string; + updatedAt: string; +} + +export interface TestResult { + success: boolean; + latencyMs: number | null; + modelCount: number | null; + modelAvailable: boolean; + testedAt: string; + message: string; +} + +export interface FormValues { + provider: AiProvider; + baseUrl: string; + apiKey: string; + defaultModel: string; + timeoutMs: number; + supportsVision: boolean; + reasoningEffort: string; +} + +export const ProviderStep: React.FC<{ + canWrite: boolean; + isFixedProvider: boolean; + config?: AiConfigData | null; + onProviderChange: (provider: AiProvider) => void; +}> = ({ canWrite, isFixedProvider, config, onProviderChange }) => { + return ( + 服务商配置} extra={}> + + + + + + + + + ); +}; + +export const KeyStep: React.FC<{ + canWrite: boolean; + config?: AiConfigData | null; + onClearKey: () => void; +}> = ({ canWrite, config, onClearKey }) => { + return ( + 密钥配置} extra={}> + + + + + {config && ( + + + {config.hasApiKey ? ( + {config.maskedApiKey || '••••'} + ) : ( + 未配置 + )} + + + {sourceLabel(config.keySource)} + {config.keySource === 'environment' && ( + + 由环境变量托管,需在服务器修改 + + )} + + {formatDateTime(config.updatedAt)} + + )} + + {config?.hasDatabaseKey && canWrite && ( +
+ +
+ )} + + {config?.keySource === 'environment' && !config.hasDatabaseKey && ( +
+ 密钥由环境变量提供,无法通过页面清除 +
+ )} + +
+ API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。传输层通过 HTTPS + 保护,服务端日志不记录密钥。 +
+
+ 也可通过环境变量 AI_API_KEY 注入密钥,环境变量优先级高于数据库存储。 +
+
+ ); +}; + +export const ModelStep: React.FC<{ + canWrite: boolean; + config?: AiConfigData | null; + onFetchModels: () => void; + fetchingModels: boolean; + modelOptions: Array<{ value: string; label: string }>; +}> = ({ canWrite, config, onFetchModels, fetchingModels, modelOptions }) => { + return ( + 模型选择} extra={}> +
+ + {modelOptions.length > 0 && {modelOptions.length} 个可用模型} +
+ + + + option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false + } + /> + + + + + + + + - - - - - - - - - -
+ ); - - // Step 1: API Key case 1: - return ( - 密钥配置} - extra={} - > - - - - - {config && ( - - - {config.hasApiKey ? ( - {config.maskedApiKey || '••••'} - ) : ( - 未配置 - )} - - - {sourceLabel(config.keySource)} - {config.keySource === 'environment' && ( - - 由环境变量托管,需在服务器修改 - - )} - - - {formatDateTime(config.updatedAt)} - - - )} - - {config?.hasDatabaseKey && canWrite && ( -
- -
- )} - - {config?.keySource === 'environment' && !config.hasDatabaseKey && ( -
- 密钥由环境变量提供,无法通过页面清除 -
- )} - -
- API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。传输层通过 HTTPS - 保护,服务端日志不记录密钥。 -
-
- 也可通过环境变量 AI_API_KEY 注入密钥, - 环境变量优先级高于数据库存储。 -
-
- ); - - // Step 2: Model selection + return ; case 2: return ( - 模型选择} - extra={} - > -
- - {modelOptions.length > 0 && ( - {modelOptions.length} 个可用模型 - )} -
- - - - option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false - } - /> - - - - - - - -