import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; import axios from 'axios'; import { APP_ENDPOINTS } from '../../../config/env'; import type { ChatMessage } from '../../../types/bot'; import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText'; import type { QuotedReply, StagedSubmissionDraft } from '../types'; import type { DashboardChatNotifyOptions } from './dashboardChatShared'; interface UseDashboardChatCommandDispatchOptions { selectedBot?: { id: string } | null; canChat: boolean; isTaskRunningExternally: boolean; commandAutoUnlockSeconds: number; command: string; pendingAttachments: string[]; quotedReply: QuotedReply | null; setCommand: Dispatch>; setPendingAttachments: Dispatch>; setQuotedReply: Dispatch>; setChatDatePickerOpen: Dispatch>; setControlCommandPanelOpen: Dispatch>; addBotMessage: (botId: string, message: Partial & Pick) => void; scrollConversationToBottom: (behavior?: ScrollBehavior) => void; completeLeadingStagedSubmission: (stagedSubmissionId: string) => void; notify: (message: string, options?: DashboardChatNotifyOptions) => void; t: any; } export function useDashboardChatCommandDispatch({ selectedBot, canChat, isTaskRunningExternally, commandAutoUnlockSeconds, command, pendingAttachments, quotedReply, setCommand, setPendingAttachments, setQuotedReply, setChatDatePickerOpen, setControlCommandPanelOpen, addBotMessage, scrollConversationToBottom, completeLeadingStagedSubmission, notify, t, }: UseDashboardChatCommandDispatchOptions) { const [sendingByBot, setSendingByBot] = useState>({}); const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState>({}); const [interruptingByBot, setInterruptingByBot] = useState>({}); const [controlCommandByBot, setControlCommandByBot] = useState>({}); 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 isTaskRunning = Boolean(selectedBot && (isSending || isTaskRunningExternally)); 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]); const submitChatPayload = useCallback(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) { completeLeadingStagedSubmission(clearStagedSubmissionId); } 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; }); } }, [ addBotMessage, canChat, commandAutoUnlockSeconds, completeLeadingStagedSubmission, notify, scrollConversationToBottom, selectedBot, setCommand, setPendingAttachments, setQuotedReply, t, ]); const sendCurrentDraft = useCallback(async () => { if (!selectedBot || !canChat || isTaskRunning) return false; const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply); if (!hasComposerDraft) return false; return submitChatPayload({ commandRaw: command, attachmentsRaw: pendingAttachments, quotedReplyRaw: quotedReply, clearComposerOnSuccess: true, }); }, [canChat, command, isTaskRunning, pendingAttachments, quotedReply, selectedBot, submitChatPayload]); const sendQueuedSubmission = useCallback(async (submission: StagedSubmissionDraft) => submitChatPayload({ commandRaw: submission.command, attachmentsRaw: submission.attachments, quotedReplyRaw: submission.quotedReply, clearComposerOnSuccess: false, clearStagedSubmissionId: submission.id, }), [submitChatPayload]); const sendControlCommand = useCallback(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; }); } }, [ activeControlCommand, canChat, notify, selectedBot, setChatDatePickerOpen, setCommand, setControlCommandPanelOpen, setPendingAttachments, setQuotedReply, t, ]); const interruptExecution = useCallback(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; }); } }, [canChat, isInterrupting, notify, selectedBot, setChatDatePickerOpen, setControlCommandPanelOpen, t]); return { activeControlCommand, interruptExecution, isInterrupting, isSending, isSendingBlocked, isTaskRunning, sendControlCommand, sendCurrentDraft, sendQueuedSubmission, }; }