smart tdee

This commit is contained in:
2026-06-16 04:38:23 +00:00
parent f2e98942ff
commit a3f01cd850
56 changed files with 2519 additions and 591 deletions
+178 -25
View File
@@ -1,4 +1,4 @@
import { FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
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";
@@ -12,11 +12,31 @@ 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;
@@ -36,7 +56,10 @@ export default function Chat() {
"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);
@@ -133,6 +156,9 @@ export default function Chat() {
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") {
@@ -368,6 +394,17 @@ export default function Chat() {
usePomodoroNotify(handlePomodoroNotify);
useEffect(() => {
return () => {
setPendingImages((prev) => {
for (const item of prev) {
URL.revokeObjectURL(item.previewUrl);
}
return prev;
});
};
}, []);
useEffect(() => {
let cancelled = false;
@@ -422,30 +459,103 @@ export default function Chat() {
}
};
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 (!input.trim() || !activeId || loading) return;
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("thinking");
setPendingPhase(submittingImages.length > 0 ? "preparing" : "thinking");
setChatError(null);
const displayContent = buildUserMessagePreview(submittingImages, text);
const tempUser: ChatMessage = {
id: nextTempId(),
role: "user",
content: text,
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: "" };
for await (const chunk of api.sendMessage(activeId, text)) {
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();
@@ -478,6 +588,9 @@ export default function Chat() {
await syncRecentMessages(activeId);
}
} finally {
for (const item of submittingImages) {
URL.revokeObjectURL(item.previewUrl);
}
setLoading(false);
if (pendingHistoryReload.current && activeId) {
pendingHistoryReload.current = false;
@@ -584,25 +697,65 @@ export default function Chat() {
/>
</div>
<form className="chat-input" onSubmit={handleSubmit}>
<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()}>
{loading ? "..." : "Отправить"}
</button>
<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>
</>
)}