336 lines
12 KiB
TypeScript
336 lines
12 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 } 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,
|
||
|
|
};
|
||
|
|
}
|