smart tdee
This commit is contained in:
+178
-25
@@ -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 ``;
|
||||
})
|
||||
.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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user