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 { mergeArtifactIntoMessage } from './uiArtifacts'; 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.onExternalArtifact = (messageId, artifact) => { setMessage(messageId, (info) => ({ message: mergeArtifactIntoMessage(info.message, artifact), })); }; }, [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, modal], ); 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, }; }