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, StagedSubmissionDraft } from '../types'; import { loadComposerDraft, persistComposerDraft } from '../utils'; const COMPOSER_MIN_ROWS = 3; const COMPOSER_MAX_HEIGHT_PX = 220; type PromptTone = 'info' | 'success' | 'warning' | 'error'; interface NotifyOptions { title?: string; tone?: PromptTone; durationMs?: number; } interface UseDashboardChatComposerOptions { selectedBotId: string; selectedBot?: { id: string } | null; canChat: boolean; isTaskRunningExternally: 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, isTaskRunningExternally, 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 [stagedSubmissionQueueByBot, setStagedSubmissionQueueByBot] = useState>({}); const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState>({}); const [interruptingByBot, setInterruptingByBot] = useState>({}); const [controlCommandByBot, setControlCommandByBot] = useState>({}); const filePickerRef = useRef(null); const composerTextareaRef = useRef(null); const stagedAutoSubmitAttemptByBotRef = useRef>({}); const selectedBotSendingCount = selectedBot ? Number(sendingByBot[selectedBot.id] || 0) : 0; const selectedBotStagedSubmissions = selectedBot ? stagedSubmissionQueueByBot[selectedBot.id] || [] : []; const nextQueuedSubmission = selectedBotStagedSubmissions[0] || null; const selectedBotAutoUnlockDeadline = selectedBot ? Number(commandAutoUnlockDeadlineByBot[selectedBot.id] || 0) : 0; const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : ''; const isSending = selectedBotSendingCount > 0; const isTaskRunning = Boolean(selectedBot && (isSending || isTaskRunningExternally)); const isCommandAutoUnlockWindowActive = selectedBotAutoUnlockDeadline > Date.now(); const isSendingBlocked = isSending && isCommandAutoUnlockWindowActive; const isInterrupting = Boolean(selectedBot && interruptingByBot[selectedBot.id]); const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply); const submitActionMode: 'interrupt' | 'send' | 'stage' = isTaskRunning ? (hasComposerDraft ? 'stage' : 'interrupt') : 'send'; 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 textarea = composerTextareaRef.current; if (!textarea || typeof window === 'undefined') return; const computed = window.getComputedStyle(textarea); const lineHeight = Number.parseFloat(computed.lineHeight) || 20; const paddingTop = Number.parseFloat(computed.paddingTop) || 0; const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0; const borderTop = Number.parseFloat(computed.borderTopWidth) || 0; const borderBottom = Number.parseFloat(computed.borderBottomWidth) || 0; const minHeight = Math.ceil((lineHeight * COMPOSER_MIN_ROWS) + paddingTop + paddingBottom + borderTop + borderBottom); textarea.style.height = 'auto'; const nextHeight = Math.min(COMPOSER_MAX_HEIGHT_PX, Math.max(minHeight, textarea.scrollHeight)); textarea.style.height = `${nextHeight}px`; textarea.style.overflowY = textarea.scrollHeight > nextHeight + 1 ? 'auto' : 'hidden'; }, [command, selectedBotId]); useEffect(() => { const hasDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply) || selectedBotStagedSubmissions.length > 0; 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, selectedBotStagedSubmissions.length]); 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 sendPayload = async ({ commandRaw, attachmentsRaw, quotedReplyRaw, clearComposerOnSuccess, clearStagedSubmissionId, }: { commandRaw: string; attachmentsRaw: string[]; quotedReplyRaw: QuotedReply | null; clearComposerOnSuccess: boolean; clearStagedSubmissionId?: string; }) => { if (!selectedBot || !canChat) return false; const attachments = [...attachmentsRaw]; const text = normalizeUserMessageText(commandRaw); const quoteText = normalizeAssistantMessageText(quotedReplyRaw?.text || ''); const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : ''; const payloadCore = text || (attachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : ''); const payloadText = `${quoteBlock}${payloadCore}`.trim(); if (!payloadText && attachments.length === 0) return false; 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 }, { timeout: 12000 }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); } addBotMessage(selectedBot.id, { role: 'user', text: payloadText, attachments, ts: Date.now(), kind: 'final', }); requestAnimationFrame(() => scrollConversationToBottom('auto')); if (clearComposerOnSuccess) { setCommand(''); setPendingAttachments([]); setQuotedReply(null); } if (clearStagedSubmissionId) { setStagedSubmissionQueueByBot((prev) => { const currentQueue = prev[selectedBot.id] || []; const current = currentQueue[0]; if (!current || current.id !== clearStagedSubmissionId) { return prev; } const remainingQueue = currentQueue.slice(1); const next = { ...prev }; if (remainingQueue.length > 0) { next[selectedBot.id] = remainingQueue; } else { delete next[selectedBot.id]; } return next; }); } return true; } 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' }); return false; } 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 send = async () => { if (!selectedBot || !canChat || isTaskRunning) return false; if (!hasComposerDraft) return false; return sendPayload({ commandRaw: command, attachmentsRaw: pendingAttachments, quotedReplyRaw: quotedReply, clearComposerOnSuccess: true, }); }; const removeStagedSubmission = (stagedSubmissionId: string) => { if (!selectedBot) return; setStagedSubmissionQueueByBot((prev) => { const currentQueue = prev[selectedBot.id] || []; const nextQueue = currentQueue.filter((item) => item.id !== stagedSubmissionId); if (nextQueue.length === currentQueue.length) return prev; const next = { ...prev }; if (nextQueue.length > 0) { next[selectedBot.id] = nextQueue; } else { delete next[selectedBot.id]; } return next; }); }; const restoreStagedSubmission = (stagedSubmissionId: string) => { if (!selectedBot) return; const targetSubmission = selectedBotStagedSubmissions.find((item) => item.id === stagedSubmissionId); if (!targetSubmission) return; setCommand(targetSubmission.command); setPendingAttachments(targetSubmission.attachments); setQuotedReply(targetSubmission.quotedReply); removeStagedSubmission(stagedSubmissionId); composerTextareaRef.current?.focus(); notify(t.stagedSubmissionRestored, { tone: 'success' }); }; const stageCurrentSubmission = () => { if (!selectedBot || !hasComposerDraft) return; const nextStagedSubmission: StagedSubmissionDraft = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, command, attachments: [...pendingAttachments], quotedReply, updated_at_ms: Date.now(), }; setStagedSubmissionQueueByBot((prev) => ({ ...prev, [selectedBot.id]: [...(prev[selectedBot.id] || []), nextStagedSubmission], })); setCommand(''); setPendingAttachments([]); setQuotedReply(null); notify(t.stagedSubmissionQueued, { tone: 'success' }); }; const handlePrimarySubmitAction = async () => { if (!selectedBot || !canChat) return; if (isTaskRunning) { if (hasComposerDraft) { stageCurrentSubmission(); return; } await interruptExecution(); return; } await send(); }; useEffect(() => { if (!selectedBot || !canChat || !nextQueuedSubmission || isTaskRunning || isUploadingAttachments) { return; } const lastAttemptedSubmissionId = stagedAutoSubmitAttemptByBotRef.current[selectedBot.id]; if (lastAttemptedSubmissionId === nextQueuedSubmission.id) { return; } stagedAutoSubmitAttemptByBotRef.current[selectedBot.id] = nextQueuedSubmission.id; void sendPayload({ commandRaw: nextQueuedSubmission.command, attachmentsRaw: nextQueuedSubmission.attachments, quotedReplyRaw: nextQueuedSubmission.quotedReply, clearComposerOnSuccess: false, clearStagedSubmissionId: nextQueuedSubmission.id, }); }, [canChat, isTaskRunning, isUploadingAttachments, nextQueuedSubmission, selectedBot]); 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 handlePrimarySubmitAction(); }; const triggerPickAttachments = () => { if (!selectedBot || !canChat || isUploadingAttachments) return; filePickerRef.current?.click(); }; return { activeControlCommand, command, composerTextareaRef, copyAssistantReply, copyUserPrompt, editUserPrompt, filePickerRef, handlePrimarySubmitAction, interruptExecution, isCommandAutoUnlockWindowActive, isInterrupting, isSending, isTaskRunning, isSendingBlocked, onComposerKeyDown, quoteAssistantReply, quotedReply, restoreStagedSubmission, removeStagedSubmission, send, sendControlCommand, setCommand, setQuotedReply, selectedBotStagedSubmissions, submitActionMode, triggerPickAttachments, }; }