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

336 lines
12 KiB
TypeScript
Raw Normal View History

2026-03-31 04:31:47 +00:00
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 } from '../types';
import { loadComposerDraft, persistComposerDraft } from '../utils';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface UseDashboardChatComposerOptions {
selectedBotId: string;
selectedBot?: { id: string } | null;
canChat: 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,
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 [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 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 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]);
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 hasDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
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]);
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 send = async () => {
if (!selectedBot || !canChat || isSendingBlocked) return;
if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return;
const text = normalizeUserMessageText(command);
const quoteText = normalizeAssistantMessageText(quotedReply?.text || '');
const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
const payloadText = `${quoteBlock}${payloadCore}`.trim();
if (!payloadText && pendingAttachments.length === 0) return;
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: pendingAttachments },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
addBotMessage(selectedBot.id, {
role: 'user',
text: payloadText,
attachments: [...pendingAttachments],
ts: Date.now(),
kind: 'final',
});
requestAnimationFrame(() => scrollConversationToBottom('auto'));
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
} 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' });
} 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 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 send();
};
const triggerPickAttachments = () => {
if (!selectedBot || !canChat || isUploadingAttachments) return;
filePickerRef.current?.click();
};
return {
activeControlCommand,
command,
composerTextareaRef,
copyAssistantReply,
copyUserPrompt,
editUserPrompt,
filePickerRef,
interruptExecution,
isCommandAutoUnlockWindowActive,
isInterrupting,
isSending,
isSendingBlocked,
onComposerKeyDown,
quoteAssistantReply,
quotedReply,
send,
sendControlCommand,
setCommand,
setQuotedReply,
triggerPickAttachments,
};
}