import { useEffect, useRef, useState, type Dispatch, type KeyboardEvent, type SetStateAction } from 'react'; import axios from 'axios'; import { APP_ENDPOINTS } from '../../../config/env'; import type { ChatMessage } from '../../../types/bot'; import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser'; import type { QuotedReply } from '../types'; import { loadComposerDraft, persistComposerDraft } from '../utils'; type PromptTone = 'info' | 'success' | 'warning' | 'error'; interface NotifyOptions { title?: string; tone?: PromptTone; durationMs?: number; } interface UseDashboardChatComposerOptions { selectedBotId: string; selectedBot?: { id: string } | null; canChat: boolean; commandAutoUnlockSeconds: number; pendingAttachments: string[]; setPendingAttachments: Dispatch>; isUploadingAttachments: boolean; setChatDatePickerOpen: Dispatch>; setControlCommandPanelOpen: Dispatch>; addBotMessage: (botId: string, message: Partial & Pick) => void; scrollConversationToBottom: (behavior?: ScrollBehavior) => void; notify: (message: string, options?: NotifyOptions) => void; t: any; } export function useDashboardChatComposer({ selectedBotId, selectedBot, canChat, commandAutoUnlockSeconds, pendingAttachments, setPendingAttachments, isUploadingAttachments, setChatDatePickerOpen, setControlCommandPanelOpen, addBotMessage, scrollConversationToBottom, notify, t, }: UseDashboardChatComposerOptions) { const [command, setCommand] = useState(''); const [composerDraftHydrated, setComposerDraftHydrated] = useState(false); const [quotedReply, setQuotedReply] = useState(null); const [sendingByBot, setSendingByBot] = useState>({}); const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState>({}); const [interruptingByBot, setInterruptingByBot] = useState>({}); const [controlCommandByBot, setControlCommandByBot] = useState>({}); const filePickerRef = useRef(null); const composerTextareaRef = useRef(null); const selectedBotSendingCount = selectedBot ? Number(sendingByBot[selectedBot.id] || 0) : 0; const selectedBotAutoUnlockDeadline = selectedBot ? Number(commandAutoUnlockDeadlineByBot[selectedBot.id] || 0) : 0; const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : ''; const isSending = selectedBotSendingCount > 0; const isCommandAutoUnlockWindowActive = selectedBotAutoUnlockDeadline > Date.now(); const isSendingBlocked = isSending && isCommandAutoUnlockWindowActive; const isInterrupting = Boolean(selectedBot && interruptingByBot[selectedBot.id]); useEffect(() => { if (!selectedBot?.id || selectedBotAutoUnlockDeadline <= 0) return; const remaining = selectedBotAutoUnlockDeadline - Date.now(); if (remaining <= 0) { setCommandAutoUnlockDeadlineByBot((prev) => { const next = { ...prev }; delete next[selectedBot.id]; return next; }); return; } const timer = window.setTimeout(() => { setCommandAutoUnlockDeadlineByBot((prev) => { const next = { ...prev }; delete next[selectedBot.id]; return next; }); }, remaining + 20); return () => window.clearTimeout(timer); }, [selectedBot?.id, selectedBotAutoUnlockDeadline]); useEffect(() => { setComposerDraftHydrated(false); if (!selectedBotId) { setCommand(''); setPendingAttachments([]); setComposerDraftHydrated(true); return; } const draft = loadComposerDraft(selectedBotId); setCommand(draft?.command || ''); setPendingAttachments(draft?.attachments || []); setComposerDraftHydrated(true); }, [selectedBotId, setPendingAttachments]); useEffect(() => { if (!selectedBotId || !composerDraftHydrated) return; persistComposerDraft(selectedBotId, command, pendingAttachments); }, [selectedBotId, composerDraftHydrated, command, pendingAttachments]); useEffect(() => { setQuotedReply(null); }, [selectedBotId]); useEffect(() => { const hasDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply); if (!hasDraft && !isUploadingAttachments) return; const onBeforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; }; window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); }, [command, isUploadingAttachments, pendingAttachments.length, quotedReply]); const copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => { const text = String(textRaw || ''); if (!text.trim()) return; try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } notify(successMsg, { tone: 'success' }); } catch { notify(failMsg, { tone: 'error' }); } }; const send = async () => { if (!selectedBot || !canChat || isSendingBlocked) return; if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return; const text = normalizeUserMessageText(command); const quoteText = normalizeAssistantMessageText(quotedReply?.text || ''); const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : ''; const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : ''); const payloadText = `${quoteBlock}${payloadCore}`.trim(); if (!payloadText && pendingAttachments.length === 0) return; try { requestAnimationFrame(() => scrollConversationToBottom('auto')); setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: Number(prev[selectedBot.id] || 0) + 1 })); setCommandAutoUnlockDeadlineByBot((prev) => ({ ...prev, [selectedBot.id]: Date.now() + (commandAutoUnlockSeconds * 1000), })); const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: payloadText, attachments: pendingAttachments }, { timeout: 12000 }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); } addBotMessage(selectedBot.id, { role: 'user', text: payloadText, attachments: [...pendingAttachments], ts: Date.now(), kind: 'final', }); requestAnimationFrame(() => scrollConversationToBottom('auto')); setCommand(''); setPendingAttachments([]); setQuotedReply(null); } catch (error: any) { const msg = error?.response?.data?.detail || error?.message || t.sendFail; setCommandAutoUnlockDeadlineByBot((prev) => { const next = { ...prev }; delete next[selectedBot.id]; return next; }); addBotMessage(selectedBot.id, { role: 'assistant', text: t.sendFailMsg(msg), ts: Date.now(), }); requestAnimationFrame(() => scrollConversationToBottom('auto')); notify(msg, { tone: 'error' }); } finally { setSendingByBot((prev) => { const next = { ...prev }; const remaining = Number(next[selectedBot.id] || 0) - 1; if (remaining > 0) { next[selectedBot.id] = remaining; } else { delete next[selectedBot.id]; } return next; }); } }; const sendControlCommand = async (slashCommand: '/new' | '/restart') => { if (!selectedBot || !canChat || activeControlCommand) return; try { setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand })); const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: slashCommand }, { timeout: 12000 }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); } if (slashCommand === '/new') { setCommand(''); setPendingAttachments([]); setQuotedReply(null); } setChatDatePickerOpen(false); setControlCommandPanelOpen(false); notify(t.controlCommandSent(slashCommand), { tone: 'success' }); } catch (error: any) { const msg = error?.response?.data?.detail || error?.message || t.sendFail; notify(msg, { tone: 'error' }); } finally { setControlCommandByBot((prev) => { const next = { ...prev }; delete next[selectedBot.id]; return next; }); } }; const interruptExecution = async () => { if (!selectedBot || !canChat || isInterrupting) return; try { setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true })); const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: '/stop' }, { timeout: 12000 }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); } setChatDatePickerOpen(false); setControlCommandPanelOpen(false); notify(t.interruptSent, { tone: 'success' }); } catch (error: any) { const msg = error?.response?.data?.detail || error?.message || t.sendFail; notify(msg, { tone: 'error' }); } finally { setInterruptingByBot((prev) => { const next = { ...prev }; delete next[selectedBot.id]; return next; }); } }; const copyUserPrompt = async (text: string) => { await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail); }; const editUserPrompt = (text: string) => { const normalized = normalizeUserMessageText(text); if (!normalized) return; setCommand(normalized); composerTextareaRef.current?.focus(); if (composerTextareaRef.current) { const caret = normalized.length; window.requestAnimationFrame(() => { composerTextareaRef.current?.setSelectionRange(caret, caret); }); } notify(t.editPromptDone, { tone: 'success' }); }; const copyAssistantReply = async (text: string) => { await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail); }; const quoteAssistantReply = (message: ChatMessage) => { const content = normalizeAssistantMessageText(message.text); if (!content) return; setQuotedReply((prev) => { if (prev && prev.ts === message.ts && normalizeAssistantMessageText(prev.text) === content) { return null; } return { id: message.id, ts: message.ts, text: content }; }); }; const onComposerKeyDown = (event: KeyboardEvent) => { const native = event.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number }; if (native.isComposing || native.keyCode === 229) return; const isEnter = event.key === 'Enter' || event.key === 'NumpadEnter'; if (!isEnter || event.shiftKey) return; event.preventDefault(); void send(); }; const triggerPickAttachments = () => { if (!selectedBot || !canChat || isUploadingAttachments) return; filePickerRef.current?.click(); }; return { activeControlCommand, command, composerTextareaRef, copyAssistantReply, copyUserPrompt, editUserPrompt, filePickerRef, interruptExecution, isCommandAutoUnlockWindowActive, isInterrupting, isSending, isSendingBlocked, onComposerKeyDown, quoteAssistantReply, quotedReply, send, sendControlCommand, setCommand, setQuotedReply, triggerPickAttachments, }; }