Files
Home_assistant/frontend/src/pages/Chat.tsx
T
2026-06-16 04:38:23 +00:00

766 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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<ChatSession[]>([]);
const [activeId, setActiveId] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
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<string | null>(null);
const [pendingImages, setPendingImages] = useState<PendingImageItem[]>([]);
const [inputDragOver, setInputDragOver] = useState(false);
const tempMessageId = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesRef = useRef<HTMLDivElement>(null);
const messageListRef = useRef<MessageListHandle>(null);
const bottomAnchorRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const stickToBottomRef = useRef(true);
const pendingScrollToBottomRef = useRef(false);
const loadingOlderRef = useRef(false);
const messagesStateRef = useRef<ChatMessage[]>([]);
const refreshPomodoro = usePomodoroRefresh();
const lastReminderNotifySeq = useRef(0);
const remindersNotifyReady = useRef(false);
const pendingHistoryReload = useRef(false);
const resumeSessionRef = useRef<number | null>(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<HTMLFormElement>) => {
event.preventDefault();
if (loading) return;
setInputDragOver(true);
};
const handleInputDragLeave = (event: DragEvent<HTMLFormElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setInputDragOver(false);
};
const handleInputDrop = (event: DragEvent<HTMLFormElement>) => {
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 (
<div className="chat-layout">
<aside className="chat-sidebar">
<button className="primary-btn" onClick={handleNewChat}>
+ Новый чат
</button>
<PomodoroWidget />
<ul className="session-list">
{sessions.map((session) => (
<li key={session.id} className={activeId === session.id ? "active" : ""}>
<button onClick={() => setActiveId(session.id)}>{session.title}</button>
<button className="delete-btn" onClick={() => handleDelete(session.id)}>
×
</button>
</li>
))}
</ul>
</aside>
<section className="chat-main">
{!activeId ? (
<div className="chat-empty">Создайте новый чат, чтобы начать</div>
) : (
<>
<div className="chat-mobile-bar">
<select
className="chat-session-select"
value={activeId}
onChange={(e) => setActiveId(Number(e.target.value))}
aria-label="Выбор чата"
>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.title}
</option>
))}
</select>
<button type="button" className="chat-mobile-new" onClick={handleNewChat}>
+ Новый
</button>
</div>
<div
className="messages"
ref={messagesRef}
onClick={dismissKeyboard}
>
{hasMoreOlder && (
<div className="messages-load-older-hint">Прокрутите вверх, чтобы загрузить историю</div>
)}
<MessageList
ref={messageListRef}
messages={visibleMessages}
containerRef={messagesRef}
/>
{waitingForStream && (
<div className="message message-assistant message-pending" aria-live="polite">
<div className="message-role">assistant</div>
<div className="message-content message-pending-content">
<span className="typing-indicator" aria-hidden="true">
<span />
<span />
<span />
</span>
<span className="typing-label">{pendingLabel}</span>
</div>
</div>
)}
{streaming && (
<div className="message message-assistant">
<div className="message-role">assistant</div>
<div className="message-content streaming-text">{streaming}</div>
</div>
)}
{chatError && (
<div className="message message-error" role="alert">
<div className="message-role">ошибка</div>
<div className="message-content">{chatError}</div>
</div>
)}
<div
className="messages-bottom-anchor"
ref={bottomAnchorRef}
aria-hidden="true"
/>
</div>
<form
className={`chat-input${inputDragOver ? " chat-input-dragover" : ""}`}
onSubmit={handleSubmit}
onDragOver={handleInputDragOver}
onDragLeave={handleInputDragLeave}
onDrop={handleInputDrop}
>
{pendingImages.length ? (
<div className="chat-image-previews">
{pendingImages.map((item, index) => (
<div key={`${item.file.name}-${index}`} className="chat-image-preview">
<img src={item.previewUrl} alt={`Превью ${index + 1}`} />
<button type="button" onClick={() => removePendingImage(index)} aria-label="Убрать">
×
</button>
</div>
))}
<button type="button" className="chat-image-clear-all" onClick={clearPendingImages}>
Очистить все
</button>
</div>
) : null}
<div className="chat-input-row">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="chat-file-input"
onChange={(e) => handleImagePick(e.target.files)}
/>
<button
type="button"
className="chat-attach-btn"
title="Прикрепить скриншоты"
onClick={() => fileInputRef.current?.click()}
disabled={loading || pendingImages.length >= MAX_PENDING_IMAGES}
>
📎
</button>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение или прикрепите скриншоты…"
rows={2}
enterKeyHint="send"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<button type="submit" disabled={loading || (!input.trim() && pendingImages.length === 0)}>
{loading ? "..." : "Отправить"}
</button>
</div>
</form>
</>
)}
</section>
</div>
);
}