281 lines
9.9 KiB
TypeScript
281 lines
9.9 KiB
TypeScript
|
|
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,
|
||
|
|
};
|
||
|
|
}
|