766 lines
25 KiB
TypeScript
766 lines
25 KiB
TypeScript
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 ``;
|
||
})
|
||
.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>
|
||
);
|
||
}
|