dashboard-nanobot/frontend/src/modules/dashboard/hooks/useDashboardChatComposer.ts

499 lines
18 KiB
TypeScript

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<SetStateAction<string[]>>;
isUploadingAttachments: boolean;
setChatDatePickerOpen: Dispatch<SetStateAction<boolean>>;
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => 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<QuotedReply | null>(null);
const [sendingByBot, setSendingByBot] = useState<Record<string, number>>({});
const [stagedSubmissionQueueByBot, setStagedSubmissionQueueByBot] = useState<Record<string, StagedSubmissionDraft[]>>({});
const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState<Record<string, number>>({});
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
const [controlCommandByBot, setControlCommandByBot] = useState<Record<string, string>>({});
const filePickerRef = useRef<HTMLInputElement | null>(null);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const stagedAutoSubmitAttemptByBotRef = useRef<Record<string, string>>({});
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<HTMLTextAreaElement>) => {
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,
};
}