dashboard-nanobot/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDisp...

281 lines
9.9 KiB
TypeScript
Raw Normal View History

2026-04-03 15:00:08 +00:00
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<SetStateAction<string>>;
setPendingAttachments: Dispatch<SetStateAction<string[]>>;
setQuotedReply: Dispatch<SetStateAction<QuotedReply | null>>;
setChatDatePickerOpen: Dispatch<SetStateAction<boolean>>;
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => 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<Record<string, number>>({});
const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState<Record<string, number>>({});
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
const [controlCommandByBot, setControlCommandByBot] = useState<Record<string, string>>({});
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,
};
}