499 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|