import { DragEvent, FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { api, ChatMessage, ChatSession, ChatStreamChunk } from "../api/client"; import MessageList, { MessageListHandle } from "../components/MessageList"; import PomodoroWidget from "../components/PomodoroWidget"; import { usePomodoroNotify, usePomodoroRefresh } from "../hooks/usePomodoroNotify"; import { useThrottledStreaming } from "../hooks/useThrottledStreaming"; import { dedupeMessages, mergeMessages, stripOptimisticMessages } from "../utils/mergeMessages"; import "./Chat.css"; import "./Chat.performance.css"; const INITIAL_MESSAGE_LIMIT = 30; const LOAD_OLDER_LIMIT = 30; const SYNC_TAIL_LIMIT = 15; const GENERATION_POLL_MS = 2000; const MAX_PENDING_IMAGES = 8; type PendingImageItem = { file: File; previewUrl: string; }; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function buildImageMarkdown(items: PendingImageItem[]): string { if (!items.length) return ""; return items .map((item, index) => { const label = items.length > 1 ? `скриншот ${index + 1}/${items.length}` : "скриншот"; return `![${label}](${item.previewUrl})`; }) .join("\n"); } function buildUserMessagePreview(items: PendingImageItem[], text: string): string { return [buildImageMarkdown(items), text.trim()].filter(Boolean).join("\n\n"); } function shouldShowMessage(msg: ChatMessage): boolean { if (msg.role === "tool") return false; if (msg.role === "assistant" && msg.tool_calls_json) return false; if (msg.role === "assistant" && !msg.content.trim()) return false; return true; } export default function Chat() { const [sessions, setSessions] = useState([]); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [hasMoreOlder, setHasMoreOlder] = useState(false); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const { streaming, setStreaming, resetStreaming, flushStreaming } = useThrottledStreaming(); const [pendingPhase, setPendingPhase] = useState< "thinking" | "preparing" | "generating" | "tools" >("thinking"); const [chatError, setChatError] = useState(null); const [pendingImages, setPendingImages] = useState([]); const [inputDragOver, setInputDragOver] = useState(false); const tempMessageId = useRef(0); const fileInputRef = useRef(null); const messagesRef = useRef(null); const messageListRef = useRef(null); const bottomAnchorRef = useRef(null); const inputRef = useRef(null); const stickToBottomRef = useRef(true); const pendingScrollToBottomRef = useRef(false); const loadingOlderRef = useRef(false); const messagesStateRef = useRef([]); const refreshPomodoro = usePomodoroRefresh(); const lastReminderNotifySeq = useRef(0); const remindersNotifyReady = useRef(false); const pendingHistoryReload = useRef(false); const resumeSessionRef = useRef(null); messagesStateRef.current = messages; const loadSessions = async () => { const data = await api.listSessions(); setSessions(data); if (!activeId && data.length > 0) { setActiveId(data[0].id); } }; const scrollToBottom = useCallback((force = false) => { if (!force && !stickToBottomRef.current) return; messageListRef.current?.scrollToBottom(); bottomAnchorRef.current?.scrollIntoView({ block: "end", behavior: "auto" }); }, []); const applyMessages = useCallback( (updater: (prev: ChatMessage[]) => ChatMessage[], options?: { preserveScroll?: boolean; scrollToBottom?: boolean }) => { const container = messagesRef.current; const wasAtBottom = stickToBottomRef.current; const preserveScroll = options?.preserveScroll ?? true; const forceBottom = options?.scrollToBottom ?? false; const prevScrollHeight = container?.scrollHeight ?? 0; const prevScrollTop = container?.scrollTop ?? 0; setMessages((prev) => dedupeMessages(updater(prev))); if (forceBottom || wasAtBottom) { pendingScrollToBottomRef.current = true; return; } if (!preserveScroll || !container) return; requestAnimationFrame(() => { requestAnimationFrame(() => { container.scrollTop = prevScrollTop + (container.scrollHeight - prevScrollHeight); }); }); }, [], ); const syncRecentMessages = useCallback(async (sessionId: number) => { const maxId = messagesStateRef.current .filter((m) => m.id > 0) .reduce((max, m) => Math.max(max, m.id), 0); const data = maxId > 0 ? await api.getSessionMessages(sessionId, { limit: SYNC_TAIL_LIMIT, after_id: maxId }) : await api.getSessionMessages(sessionId, { limit: SYNC_TAIL_LIMIT }); if (data.messages.length === 0) return; applyMessages((prev) => mergeMessages(stripOptimisticMessages(prev), data.messages)); }, [applyMessages]); const nextTempId = () => { tempMessageId.current -= 1; return tempMessageId.current; }; const appendNotice = useCallback((content: string) => { applyMessages((prev) => { const last = prev[prev.length - 1]; if (last?.role === "notice" && last.content === content) { return prev; } return [ ...prev, { id: nextTempId(), role: "notice", content, created_at: new Date().toISOString(), }, ]; }); }, [applyMessages]); const processStreamChunk = useCallback( (chunk: ChatStreamChunk, assistantTextRef: { current: string }) => { if (chunk.event === "vision") { setPendingPhase("preparing"); } if (chunk.event === "status") { const phase = chunk.data.phase; if (phase === "preparing") { setPendingPhase("preparing"); } if (phase === "generating") { setPendingPhase("generating"); } if (phase === "tools") { setPendingPhase("tools"); assistantTextRef.current = ""; resetStreaming(); } } if (chunk.event === "token") { assistantTextRef.current += String(chunk.data.content ?? ""); setPendingPhase("generating"); setStreaming(assistantTextRef.current); } if (chunk.event === "notice") { appendNotice(String(chunk.data.content ?? "")); if (String(chunk.data.content).startsWith("⏱")) { refreshPomodoro(); } } if (chunk.event === "pomodoro") { refreshPomodoro(); } if (chunk.event === "error") { throw new Error(String(chunk.data.message ?? "Ошибка чата")); } }, [appendNotice, refreshPomodoro, resetStreaming, setStreaming], ); const waitForServerGeneration = useCallback( async (sessionId: number) => { while (true) { const status = await api.getGenerationStatus(sessionId); await syncRecentMessages(sessionId); if (!status.active) { break; } await sleep(GENERATION_POLL_MS); } }, [syncRecentMessages], ); const resumeOngoingGeneration = useCallback( async (sessionId: number) => { if (resumeSessionRef.current === sessionId) return; let status; try { status = await api.getGenerationStatus(sessionId); } catch { return; } if (!status.active) return; resumeSessionRef.current = sessionId; setLoading(true); setPendingPhase("generating"); setChatError(null); stickToBottomRef.current = true; const pollId = window.setInterval(() => { syncRecentMessages(sessionId).catch(console.error); }, GENERATION_POLL_MS); const assistantTextRef = { current: "" }; try { for await (const chunk of api.streamGeneration(sessionId)) { processStreamChunk(chunk, assistantTextRef); if (chunk.event === "done") { break; } } } catch (err) { console.error(err); try { await waitForServerGeneration(sessionId); } catch (pollErr) { console.error(pollErr); } } finally { clearInterval(pollId); flushStreaming(); resetStreaming(); await syncRecentMessages(sessionId); await loadSessions(); setLoading(false); resumeSessionRef.current = null; } }, [ flushStreaming, processStreamChunk, resetStreaming, syncRecentMessages, waitForServerGeneration, ], ); const loadInitialMessages = useCallback(async (sessionId: number) => { const data = await api.getSessionMessages(sessionId, { limit: INITIAL_MESSAGE_LIMIT }); setHasMoreOlder(data.has_more); setMessages(dedupeMessages(data.messages)); stickToBottomRef.current = true; pendingScrollToBottomRef.current = true; await resumeOngoingGeneration(sessionId); }, [resumeOngoingGeneration]); const loadOlderMessages = useCallback(async () => { if (!activeId || loadingOlderRef.current || !hasMoreOlder) return; const oldest = messagesStateRef.current.find((m) => m.id > 0); if (!oldest) return; loadingOlderRef.current = true; const container = messagesRef.current; const prevScrollHeight = container?.scrollHeight ?? 0; const prevScrollTop = container?.scrollTop ?? 0; try { const data = await api.getSessionMessages(activeId, { limit: LOAD_OLDER_LIMIT, before_id: oldest.id, }); setHasMoreOlder(data.has_more); applyMessages((prev) => dedupeMessages([...data.messages, ...prev]), { preserveScroll: false }); requestAnimationFrame(() => { if (container) { container.scrollTop = prevScrollTop + (container.scrollHeight - prevScrollHeight); } }); } catch (err) { console.error(err); } finally { loadingOlderRef.current = false; } }, [activeId, applyMessages, hasMoreOlder]); useEffect(() => { loadSessions().catch(console.error); }, []); useEffect(() => { if (!activeId) { setMessages([]); setHasMoreOlder(false); return; } stickToBottomRef.current = true; loadInitialMessages(activeId).catch(console.error); }, [activeId, loadInitialMessages]); const updateStickiness = useCallback(() => { const container = messagesRef.current; if (!container) return; const distance = container.scrollHeight - container.scrollTop - container.clientHeight; stickToBottomRef.current = distance < 150; }, []); const handleMessagesScroll = useCallback(() => { updateStickiness(); const container = messagesRef.current; if (!container || container.scrollTop > 120) return; loadOlderMessages().catch(console.error); }, [loadOlderMessages, updateStickiness]); useLayoutEffect(() => { if (!pendingScrollToBottomRef.current) return; pendingScrollToBottomRef.current = false; scrollToBottom(true); }, [messages, streaming, scrollToBottom]); useEffect(() => { const container = messagesRef.current; if (!container || !activeId) return; container.addEventListener("scroll", handleMessagesScroll, { passive: true }); updateStickiness(); let rafId: number | null = null; const scheduleScroll = () => { if (rafId !== null) return; rafId = requestAnimationFrame(() => { rafId = null; scrollToBottom(); }); }; const observer = new ResizeObserver(scheduleScroll); observer.observe(container); return () => { container.removeEventListener("scroll", handleMessagesScroll); observer.disconnect(); if (rafId !== null) { cancelAnimationFrame(rafId); } }; }, [activeId, handleMessagesScroll, scrollToBottom, updateStickiness]); const dismissKeyboard = useCallback(() => { inputRef.current?.blur(); }, []); const waitingForStream = loading && !streaming; const pendingLabel = pendingPhase === "tools" ? "Выполняю команды…" : pendingPhase === "preparing" ? "Собираю контекст…" : pendingPhase === "generating" ? "Генерирую ответ…" : "Думаю…"; const handlePomodoroNotify = useCallback(() => { refreshPomodoro().catch(console.error); if (!activeId) return; if (loading) { pendingHistoryReload.current = true; } else { syncRecentMessages(activeId).catch(console.error); } }, [activeId, loading, refreshPomodoro, syncRecentMessages]); usePomodoroNotify(handlePomodoroNotify); useEffect(() => { return () => { setPendingImages((prev) => { for (const item of prev) { URL.revokeObjectURL(item.previewUrl); } return prev; }); }; }, []); useEffect(() => { let cancelled = false; const poll = async () => { try { const data = await api.getRemindersSnapshot(); if (cancelled) return; if (!remindersNotifyReady.current) { remindersNotifyReady.current = true; lastReminderNotifySeq.current = data.notify_seq; return; } if (data.notify_seq > lastReminderNotifySeq.current) { lastReminderNotifySeq.current = data.notify_seq; if (activeId) { if (loading) { pendingHistoryReload.current = true; } else { syncRecentMessages(activeId).catch(console.error); } } } } catch { // ignore polling errors } }; poll().catch(console.error); const id = setInterval(() => poll().catch(console.error), 60000); return () => { cancelled = true; clearInterval(id); }; }, [activeId, loading, syncRecentMessages]); const handleNewChat = async () => { const session = await api.createSession(); await loadSessions(); setActiveId(session.id); setMessages([]); setHasMoreOlder(false); }; const handleDelete = async (id: number) => { await api.deleteSession(id); const data = await api.listSessions(); setSessions(data); if (activeId === id) { setActiveId(data[0]?.id ?? null); setMessages([]); setHasMoreOlder(false); } }; const clearPendingImages = useCallback(() => { setPendingImages((prev) => { for (const item of prev) { URL.revokeObjectURL(item.previewUrl); } return []; }); if (fileInputRef.current) { fileInputRef.current.value = ""; } }, []); const removePendingImage = useCallback((index: number) => { setPendingImages((prev) => { const next = [...prev]; const [removed] = next.splice(index, 1); if (removed) { URL.revokeObjectURL(removed.previewUrl); } return next; }); }, []); const handleImagePick = (fileList: FileList | null) => { if (!fileList?.length) return; const picked = Array.from(fileList).filter((file) => file.type.startsWith("image/")); if (!picked.length) return; setPendingImages((prev) => { const room = MAX_PENDING_IMAGES - prev.length; if (room <= 0) return prev; const nextItems = picked.slice(0, room).map((file) => ({ file, previewUrl: URL.createObjectURL(file), })); return [...prev, ...nextItems]; }); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const handleInputDragOver = (event: DragEvent) => { event.preventDefault(); if (loading) return; setInputDragOver(true); }; const handleInputDragLeave = (event: DragEvent) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; setInputDragOver(false); }; const handleInputDrop = (event: DragEvent) => { event.preventDefault(); setInputDragOver(false); if (loading) return; handleImagePick(event.dataTransfer.files); }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!activeId || loading) return; const text = input.trim(); const submittingImages = [...pendingImages]; if (!text && submittingImages.length === 0) return; setInput(""); dismissKeyboard(); stickToBottomRef.current = true; setLoading(true); resetStreaming(); setPendingPhase(submittingImages.length > 0 ? "preparing" : "thinking"); setChatError(null); const displayContent = buildUserMessagePreview(submittingImages, text); const tempUser: ChatMessage = { id: nextTempId(), role: "user", content: displayContent, created_at: new Date().toISOString(), }; applyMessages((prev) => [...prev, tempUser], { scrollToBottom: true }); const imageFiles = submittingImages.map((item) => item.file); setPendingImages([]); if (fileInputRef.current) { fileInputRef.current.value = ""; } try { const assistantTextRef = { current: "" }; const stream = imageFiles.length ? api.sendMessageWithImages(activeId, text, imageFiles) : api.sendMessage(activeId, text); for await (const chunk of stream) { processStreamChunk(chunk, assistantTextRef); if (chunk.event === "done") { flushStreaming(); resetStreaming(); setChatError(null); await syncRecentMessages(activeId); await loadSessions(); } } } catch (err) { console.error(err); if (activeId) { try { const status = await api.getGenerationStatus(activeId); if (status.active) { setChatError(null); setLoading(true); await waitForServerGeneration(activeId); await loadSessions(); return; } } catch { // ignore status probe errors } } const message = err instanceof Error ? err.message : "Ошибка чата"; setChatError(message); resetStreaming(); if (activeId) { await syncRecentMessages(activeId); } } finally { for (const item of submittingImages) { URL.revokeObjectURL(item.previewUrl); } setLoading(false); if (pendingHistoryReload.current && activeId) { pendingHistoryReload.current = false; syncRecentMessages(activeId).catch(console.error); } } }; const visibleMessages = useMemo( () => messages.filter(shouldShowMessage), [messages], ); return (
{!activeId ? (
Создайте новый чат, чтобы начать
) : ( <>
{hasMoreOlder && (
Прокрутите вверх, чтобы загрузить историю
)} {waitingForStream && (
assistant
)} {streaming && (
assistant
{streaming}
)} {chatError && (
ошибка
{chatError}
)}
{pendingImages.length ? (
{pendingImages.map((item, index) => (
{`Превью
))}
) : null}
handleImagePick(e.target.files)} />