import { useState, useEffect, useRef, useCallback } from "react"; import type { DB } from "../lib/db"; import type { ChatTokenTotals, Message, Usage, ModelInfo, ServerStatus } from "../lib/types"; import type { RuntimeModelFile } from "../features/runtime/types"; import { buildChatTitle, parseAssistantResponse } from "../lib/chat"; import { renderMarkdown, ensureHljs } from "../lib/markdown"; import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state"; import MessageBubble from "./MessageBubble"; import ModelSelectorPill from "./ModelSelectorPill"; import { useMemoryExtractor } from "../hooks/useMemoryExtractor"; import { getDisplayName } from "../lib/modelAlias"; import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight } from "@phosphor-icons/react"; import "./ChatView.css"; const MAX_MEMORY = 20; /** Model used for background memory extraction (small/fast). */ const MEMORY_EXTRACTOR_MODEL = "smollm2"; interface Props { db: DB; chatId: number | null; server: string; apiKey: string; model: string; models: ModelInfo[]; localModels: RuntimeModelFile[]; selectedModel: string; onModelChange: (id: string) => void; onModelSwap?: (path: string) => void; isSwapping?: boolean; status: ServerStatus; onChatCreated: (id: number) => void; onChatUpdated: () => void; onOpenSidebar?: () => void; onDegradationDetected?: () => void; } export default function ChatView({ db, chatId, server, apiKey, model, models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status, onChatCreated, onChatUpdated, onOpenSidebar, onDegradationDetected }: Props) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [generating, setGenerating] = useState(false); const [streamContent, setStreamContent] = useState(""); const [streamReasoning, setStreamReasoning] = useState(""); const [streamThinkingOpen, setStreamThinkingOpen] = useState(false); const [showLiveReasoning, setShowLiveReasoning] = useState(false); const [tps, setTps] = useState(null); const [useStream, setUseStream] = useState(true); const [useMemory, setUseMemory] = useState(true); const [streamTokenCount, setStreamTokenCount] = useState(0); const [messageQueue, setMessageQueue] = useState([]); const [showMemoryModal, setShowMemoryModal] = useState(false); const [isQueueOpen, setIsQueueOpen] = useState(false); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const abortRef = useRef(null); const activeChatIdRef = useRef(chatId); const pendingRequestChatIdRef = useRef(null); const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {}); const messageQueueRef = useRef([]); const sendRef = useRef<(text: string, fromChatId?: number, replaceAssistantId?: number) => Promise>(async () => {}); // Tracks accumulated stream content so abort can persist it const streamContentAccRef = useRef(""); // TPS degradation detection: timer fires if TPS stays < 30 for 5s const degradationTimerRef = useRef | null>(null); // Single helper to keep ref and state in sync — use at every queue mutation site const setQueue = (next: string[]) => { messageQueueRef.current = next; setMessageQueue(next); }; const composerNotice = getComposerNotice(model, server); const starterPrompts = getSuggestedPrompts(); const estimatedTokens = input.length > 0 ? Math.ceil(input.length / 4) : 0; const chatTotals = messages.reduce((acc, msg) => { acc.prompt_tokens += msg.prompt_tokens ?? 0; acc.completion_tokens += msg.completion_tokens ?? 0; acc.total_tokens += msg.total_tokens ?? 0; return acc; }, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); const memoryMessageCount = chatId != null ? Math.min( messages.filter(m => m.content && !m.content.startsWith("⚠️") && m.content !== "internal error").length, useMemory ? MAX_MEMORY : 1, ) : 0; // ── Memory extractor (background, fire-and-forget) ──────────────────────── const { extractMemories, buildMemorySystemPrompt } = useMemoryExtractor({ db, server, apiKey, memoryModel: MEMORY_EXTRACTOR_MODEL, enabled: useMemory, }); useEffect(() => { ensureHljs(); }, []); // TPS degradation detection: if TPS < 30 for 5 continuous seconds, notify useEffect(() => { if (tps === null) return; if (tps < 30) { if (!degradationTimerRef.current) { degradationTimerRef.current = setTimeout(() => { onDegradationDetected?.(); degradationTimerRef.current = null; }, 5000); } } else { // TPS recovered — cancel pending timer if (degradationTimerRef.current) { clearTimeout(degradationTimerRef.current); degradationTimerRef.current = null; } } return () => { if (degradationTimerRef.current) { clearTimeout(degradationTimerRef.current); degradationTimerRef.current = null; } }; }, [tps, onDegradationDetected]); const loadMessages = useCallback((targetChatId: number | null = chatId) => { if (targetChatId == null) { setMessages([]); return; } const rows = db.query( "SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC", [targetChatId], ); setMessages(rows); }, [db, chatId]); loadMessagesRef.current = loadMessages; useEffect(() => { loadMessages(); }, [loadMessages]); useEffect(() => { activeChatIdRef.current = chatId; const switchedAway = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current; if (abortRef.current && switchedAway) { abortRef.current.abort(); abortRef.current = null; messageQueueRef.current = []; setMessageQueue([]); setGenerating(false); setStreamContent(""); setStreamReasoning(""); setStreamThinkingOpen(false); setShowLiveReasoning(false); setTps(null); setStreamTokenCount(0); pendingRequestChatIdRef.current = null; return; } if (!abortRef.current) { setGenerating(false); setStreamContent(""); setStreamReasoning(""); setStreamThinkingOpen(false); setShowLiveReasoning(false); setTps(null); setStreamTokenCount(0); } }, [chatId]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamContent]); const autoResize = () => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 200) + "px"; }; const getOrCreateChat = useCallback((firstMessage?: string): number => { if (chatId != null) return chatId; const newId = db.insert("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]); onChatCreated(newId); return newId; }, [chatId, db, onChatCreated]); const ensureChatTitle = useCallback((cid: number, firstMsg: string) => { const title = buildChatTitle(firstMsg); db.run( "UPDATE chats SET title = CASE WHEN title = 'New Chat' OR title = '' THEN ? ELSE title END, updated_at = strftime('%s','now') WHERE id = ?", [title, cid], ); onChatUpdated(); }, [db, onChatUpdated]); const buildApiMessages = useCallback((cid: number) => { const limit = useMemory ? MAX_MEMORY : 1; const history = db.query<{ role: string; content: string }>( "SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE '⚠️ Error:%' AND content != 'internal error' ORDER BY created_at DESC LIMIT ?", [cid, limit], ).reverse(); // Prepend system prompt with user memories when memory mode is on const systemPrompt = useMemory ? buildMemorySystemPrompt() : ""; if (systemPrompt) { return [{ role: "system", content: systemPrompt }, ...history]; } return history; }, [db, useMemory, buildMemorySystemPrompt]); const send = useCallback(async (text: string, fromChatId?: number, replaceAssistantId?: number) => { if (!text.trim() || generating) return; if (getComposerNotice(model, server)) return; setGenerating(true); setStreamContent(""); setStreamReasoning(""); setStreamThinkingOpen(false); setShowLiveReasoning(false); setStreamTokenCount(0); streamContentAccRef.current = ""; const cid = fromChatId ?? getOrCreateChat(text); activeChatIdRef.current = cid; pendingRequestChatIdRef.current = cid; // When replaceAssistantId is set we are resending — the user message already // exists in the DB; we only delete the old assistant reply and replace it. if (replaceAssistantId == null) { db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]); const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]); if (count?.c === 1) ensureChatTitle(cid, text); } else { // Delete the old assistant message that we're replacing db.run("DELETE FROM messages WHERE id = ?", [replaceAssistantId]); } loadMessagesRef.current(cid); onChatUpdated(); const apiMessages = buildApiMessages(cid); const headers: Record = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; abortRef.current = new AbortController(); try { const res = await fetch(`${server}/v1/chat/completions`, { method: "POST", headers, signal: abortRef.current.signal, body: JSON.stringify({ model, messages: apiMessages, stream: useStream, stream_options: useStream ? { include_usage: true } : undefined, max_tokens: 2048, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as { error?: { message?: string } })?.error?.message || `HTTP ${res.status}`); } let fullContent = ""; let usage: Usage | null = null; let tokenCount = 0; if (useStream) { const reader = res.body!.getReader(); const decoder = new TextDecoder('utf-8'); let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split("\n"); buf = lines.pop()!; for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = line.slice(6).trim(); if (data === "[DONE]") continue; try { const chunk = JSON.parse(data); const delta = chunk.choices?.[0]?.delta?.content; if (delta) { tokenCount++; setStreamTokenCount(tokenCount); fullContent += delta; streamContentAccRef.current = fullContent; const parsed = parseAssistantResponse(fullContent); setStreamContent(parsed.content); setStreamReasoning(parsed.reasoning); setStreamThinkingOpen(parsed.thinkingOpen); if (parsed.reasoning) setShowLiveReasoning(true); } if (chunk.usage) usage = chunk.usage; } catch { /* ignore malformed chunks */ } } } } else { const data = await res.json(); fullContent = data.choices?.[0]?.message?.content || ""; streamContentAccRef.current = fullContent; usage = data.usage; const parsed = parseAssistantResponse(fullContent); setStreamContent(parsed.content); setStreamReasoning(parsed.reasoning); setStreamThinkingOpen(parsed.thinkingOpen); if (parsed.reasoning) setShowLiveReasoning(true); } const parsed = parseAssistantResponse(fullContent); const finalContent = parsed.content.trim() || fullContent.trim(); if (finalContent || parsed.reasoning) { db.run( `INSERT INTO messages ( chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second ) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`, [ cid, finalContent, parsed.content.trim() ? parsed.reasoning : null, usage?.prompt_tokens ?? 0, usage?.completion_tokens ?? 0, usage?.total_tokens ?? 0, usage?.tokens_per_second ?? null, ], ); } db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]); if (usage?.tokens_per_second) setTps(usage.tokens_per_second); onChatUpdated(); // Fire background memory extraction (non-blocking) setTimeout(() => { extractMemories(cid).catch(() => undefined); }, 500); } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") { // ── Save partial response on user-initiated stop ────────────────── const partial = streamContentAccRef.current; if (partial.trim()) { const parsed = parseAssistantResponse(partial); const partialContent = parsed.content.trim() || partial.trim(); db.run( `INSERT INTO messages (chat_id, role, content, reasoning_content) VALUES (?, 'assistant', ?, ?)`, [cid, `${partialContent} _(geração cancelada)_`, parsed.reasoning || null], ); db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]); onChatUpdated(); } } else if (err instanceof Error) { db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [ cid, `⚠️ Error: ${err.message}`, ]); onChatUpdated(); } } finally { setGenerating(false); setStreamContent(""); setStreamReasoning(""); setStreamThinkingOpen(false); setShowLiveReasoning(false); setStreamTokenCount(0); streamContentAccRef.current = ""; if (activeChatIdRef.current === cid) { loadMessagesRef.current(cid); } pendingRequestChatIdRef.current = null; abortRef.current = null; // Process message queue const nextInQueue = messageQueueRef.current[0]; if (nextInQueue && activeChatIdRef.current === cid) { setQueue(messageQueueRef.current.slice(1)); setTimeout(() => sendRef.current(nextInQueue), 100); } } }, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories]); // Keep sendRef always pointing to latest send closure sendRef.current = send; const handleSend = () => { const text = input.trim(); if (!text || composerNotice) return; setInput(""); if (textareaRef.current) textareaRef.current.style.height = "auto"; if (generating) { setQueue([...messageQueueRef.current, text]); return; } send(text); }; const handleStop = () => { abortRef.current?.abort(); setQueue([]); }; const handleDeleteMessage = (id: number) => { if (!window.confirm("Delete this message?")) return; db.run("DELETE FROM messages WHERE id = ?", [id]); loadMessages(); }; const handleStarterPrompt = (prompt: string) => { setInput(prompt); requestAnimationFrame(() => { textareaRef.current?.focus(); autoResize(); }); }; const handleEditAndResend = (msg: Message, newContent: string) => { db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]); db.run("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]); loadMessages(); if (generating) { setQueue([...messageQueueRef.current, newContent]); } else { send(newContent, msg.chat_id); } }; /** * Resend: regenerate the assistant reply for an existing assistant message. * Finds the preceding user message and calls send() with replaceAssistantId * so the old assistant row is replaced — no duplicate user message is inserted. */ const handleResend = useCallback((assistantMsg: Message) => { if (generating) return; // Find the user message immediately before this assistant message const userMsg = db.get( `SELECT * FROM messages WHERE chat_id = ? AND id < ? AND role = 'user' ORDER BY id DESC LIMIT 1`, [assistantMsg.chat_id, assistantMsg.id], ); if (!userMsg) return; // Delete any messages after the user message up to and including this assistant msg db.run( "DELETE FROM messages WHERE chat_id = ? AND id >= ? AND id <= ?", [assistantMsg.chat_id, userMsg.id + 1, assistantMsg.id], ); loadMessages(); send(userMsg.content, assistantMsg.chat_id, assistantMsg.id); }, [generating, db, send, loadMessages]); const handleEditAssistant = (id: number, newContent: string) => { db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, id]); loadMessages(); }; const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : []; const isInitialScreen = chatId === null && messages.length === 0; const modelShort = getDisplayName(model); const workspaceStateLabel = chatId === null ? "Workspace" : "Conversation"; const workspaceStateCopy = composerNotice ? composerNotice.title : server ? "Runtime ready" : "Runtime offline"; const heroCards = [ { title: "Personalize ForgeLM", copy: "Tune workspace defaults, preferred prompts, and local runtime behavior.", }, ]; return (
{/* ── Top bar ── */}
{onOpenSidebar && ( )} {model && (
{!isInitialScreen &&
} {modelShort}
)}
{isInitialScreen ? null : chatId !== null && chatTotals.total_tokens > 0 ? (
↑ {chatTotals.prompt_tokens.toLocaleString()} ↓ {chatTotals.completion_tokens.toLocaleString()} Σ {chatTotals.total_tokens.toLocaleString()}
) : (
{workspaceStateLabel} {workspaceStateCopy}
)}
{server ? "Runtime ready" : "Runtime offline"} {!isInitialScreen && generating && streamTokenCount > 0 && (
{streamTokenCount.toLocaleString()} tokens
)} {!isInitialScreen && tps !== null && !generating && ( {tps.toFixed(1)} tok/s )} {!isInitialScreen && ( <>
)}
{/* ── Memory Modal ── */} {showMemoryModal && (
setShowMemoryModal(false)}>
e.stopPropagation()}>
Memory Context
{memoryMessages.length} message{memoryMessages.length !== 1 ? "s" : ""} · {useMemory ? `last ${MAX_MEMORY}` : "single turn"} mode
{memoryMessages.length === 0 ? (
No messages in context yet. Start a conversation to populate memory.
) : ( memoryMessages.map((msg, i) => (
{msg.role} {msg.content.length > 240 ? msg.content.slice(0, 238) + "…" : msg.content}
)) )}
)} {/* ── Chat Layout ── */}
{/* Scrollable message area */}
{chatId === null && messages.length === 0 && (
ForgeLM workspace

What can I do for you?