Initial ForgeLM import

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-29 19:10:57 -03:00
commit a392b9dc60
4296 files changed
+1212675

No files matched your search

+511
View File
@@ -0,0 +1,511 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { DB } from "../lib/db";
import type { ChatTokenTotals, Message, Usage } from "../lib/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 "./ChatView.css";
const MAX_MEMORY = 20;
interface Props {
db: DB;
chatId: number | null;
server: string;
apiKey: string;
model: string;
onChatCreated: (id: number) => void;
onChatUpdated: () => void;
onOpenSidebar?: () => void;
}
export default function ChatView({ db, chatId, server, apiKey, model, onChatCreated, onChatUpdated, onOpenSidebar }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
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<number | null>(null);
const [useStream, setUseStream] = useState(true);
const [useMemory, setUseMemory] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const activeChatIdRef = useRef<number | null>(chatId);
const pendingRequestChatIdRef = useRef<number | null>(null);
const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {});
const composerNotice = getComposerNotice(model, server);
const starterPrompts = getSuggestedPrompts();
const chatTotals = messages.reduce<ChatTokenTotals>((totals, message) => {
totals.prompt_tokens += message.prompt_tokens ?? 0;
totals.completion_tokens += message.completion_tokens ?? 0;
totals.total_tokens += message.total_tokens ?? 0;
return totals;
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
useEffect(() => { ensureHljs(); }, []);
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
console.log(`[ChatView] loadMessages: chatId=${targetChatId}`);
if (targetChatId == null) { console.trace("[ChatView] loadMessages: no chatId — clearing messages, called from"); setMessages([]); return; }
const rows = db.query<Message>(
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[targetChatId]
);
console.log(`[ChatView] loadMessages: loaded ${rows.length} messages for chat ${targetChatId}`, rows.map(r => ({ id: r.id, role: r.role, contentLen: r.content?.length ?? 0 })));
console.trace(`[ChatView] loadMessages: setMessages(${rows.length}) called from`);
setMessages(rows);
}, [db, chatId]);
// Keep ref always pointing to latest loadMessages so async closures don't go stale
loadMessagesRef.current = loadMessages;
useEffect(() => { loadMessages(); }, [loadMessages]);
useEffect(() => {
activeChatIdRef.current = chatId;
const switchedAwayFromPendingChat = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current;
if (abortRef.current && switchedAwayFromPendingChat) {
abortRef.current.abort();
abortRef.current = null;
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setTps(null);
pendingRequestChatIdRef.current = null;
return;
}
if (!abortRef.current) {
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setTps(null);
}
}, [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 || "")]);
console.log(`[ChatView] getOrCreateChat: created chat id=${newId}`);
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;
return db.query<{ role: string; content: string }>(
"SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at DESC LIMIT ?",
[cid, limit]
).reverse();
}, [db, useMemory]);
const send = useCallback(async (text: string, fromChatId?: number) => {
console.log(`[ChatView] send: text="${text.slice(0, 60)}…" fromChatId=${fromChatId} generating=${generating} model=${model} server=${server}`);
if (!text.trim() || generating) { console.warn("[ChatView] send: blocked — empty text or already generating"); return; }
if (getComposerNotice(model, server)) { console.warn("[ChatView] send: blocked — composerNotice active"); return; }
setGenerating(true);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
const cid = fromChatId ?? getOrCreateChat(text);
console.log(`[ChatView] send: using chatId=${cid}`);
activeChatIdRef.current = cid;
pendingRequestChatIdRef.current = cid;
// Save user message
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]);
console.log(`[ChatView] send: inserted user message, chat now has ${count?.c} messages`);
if (count?.c === 1) ensureChatTitle(cid, text);
loadMessagesRef.current(cid);
onChatUpdated();
const apiMessages = buildApiMessages(cid);
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
abortRef.current = new AbortController();
console.log(`[ChatView] send: fetching ${server}/v1/chat/completions — stream=${useStream}, messages=${apiMessages.length}`);
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,
}),
});
console.log(`[ChatView] send: response status=${res.status} ok=${res.ok}`);
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) {
console.log("[ChatView] send: starting stream read");
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) { console.log(`[ChatView] send: stream done, total tokens received=${tokenCount}`); 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]") { console.log("[ChatView] send: received [DONE]"); continue; }
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
tokenCount++;
fullContent += delta;
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true);
}
if (chunk.usage) { usage = chunk.usage; console.log("[ChatView] send: received usage", usage); }
} catch (parseErr) { console.warn("[ChatView] send: failed to parse chunk", parseErr, data); }
}
}
console.log(`[ChatView] send: stream complete — fullContent.length=${fullContent.length}`);
} else {
console.log("[ChatView] send: reading blocking response");
const data = await res.json();
fullContent = data.choices?.[0]?.message?.content || "";
usage = data.usage;
console.log(`[ChatView] send: blocking response — fullContent.length=${fullContent.length}, usage=`, usage);
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true);
}
// Save assistant message
const parsed = parseAssistantResponse(fullContent);
const finalContent = parsed.content.trim() || fullContent.trim();
console.log(`[ChatView] send: saving assistant message — finalContent.length=${finalContent.length}, reasoning.length=${parsed.reasoning?.length ?? 0}, usage=`, usage);
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();
console.log(`[ChatView] send: assistant message saved to DB for chat ${cid}`);
} catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") {
console.error(`[ChatView] send: error — ${err.message}`, err);
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [
cid,
`⚠️ Error: ${err.message}`,
]);
onChatUpdated();
} else {
console.log("[ChatView] send: aborted");
}
} finally {
console.log(`[ChatView] send: finally — activeChatIdRef=${activeChatIdRef.current} cid=${cid}`);
// Direct DB check before reload
const dbCheck = db.query<{ id: number; role: string; content: string }>(
"SELECT id, role, substr(content,1,40) as content FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[cid]
);
console.log(`[ChatView] send: DB has ${dbCheck.length} messages for chat ${cid}:`, dbCheck);
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
if (activeChatIdRef.current === cid) {
console.log(`[ChatView] send: reloading messages for chat ${cid}`);
loadMessagesRef.current(cid);
} else {
console.warn(`[ChatView] send: skipping reload — active chat changed to ${activeChatIdRef.current}`);
}
pendingRequestChatIdRef.current = null;
abortRef.current = null;
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated]);
const handleSend = () => {
const text = input.trim();
if (!text || composerNotice) return;
setInput("");
if (textareaRef.current) { textareaRef.current.style.height = "auto"; }
send(text);
};
const handleStop = () => { abortRef.current?.abort(); };
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();
send(newContent, msg.chat_id);
};
const handleEditAssistant = (id: number, newContent: string) => {
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, id]);
loadMessages();
};
return (
<div className="chat-view">
{/* Top bar */}
<div className="chat-topbar">
<div className="chat-topbar-left">
{onOpenSidebar && (
<button
type="button"
className="chat-menu-btn"
onClick={onOpenSidebar}
aria-label="Open navigation"
>
☰
</button>
)}
{chatId !== null && chatTotals.total_tokens > 0 && (
<div className="chat-token-summary">
<span>Sent {chatTotals.prompt_tokens}</span>
<span>Generated {chatTotals.completion_tokens}</span>
<span>Total {chatTotals.total_tokens}</span>
</div>
)}
{tps !== null && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
)}
</div>
<div className="chat-topbar-right">
<label className="toggle-label">
<input type="checkbox" checked={useStream} onChange={(e) => setUseStream(e.target.checked)} />
Streaming
</label>
<label className="toggle-label">
<input type="checkbox" checked={useMemory} onChange={(e) => setUseMemory(e.target.checked)} />
Memory
</label>
</div>
</div>
{/* Messages */}
<div className="messages-area">
{chatId === null && messages.length === 0 && (
<div className="empty-state">
<div className="empty-state-panel">
<div className="empty-icon">⚡</div>
<span className="empty-kicker">Local-first coding workspace</span>
<h2>Start with a prompt that moves the project forward.</h2>
<p>
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
iterate, and debug without losing context.
</p>
<div className="empty-state-actions">
{starterPrompts.map((prompt) => (
<button
key={prompt}
type="button"
className="starter-chip"
onClick={() => handleStarterPrompt(prompt)}
>
{prompt}
</button>
))}
</div>
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
</div>
)}
</div>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onDelete={handleDeleteMessage}
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
renderMarkdown={renderMarkdown}
/>
))}
{/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
{(streamReasoning || streamThinkingOpen) && (
<div className="reasoning-panel live-reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowLiveReasoning((value) => !value)}
type="button"
>
<span>{showLiveReasoning ? "▼" : "▶"}</span>
<span>{streamThinkingOpen ? "Thinking..." : "Thinking"}</span>
</button>
{showLiveReasoning && streamReasoning && (
<div
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
/>
)}
</div>
)}
{streamContent && (
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
</div>
)}
</div>
)}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="input-area">
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
</div>
)}
<div className="input-row">
<textarea
ref={textareaRef}
className="input-textarea"
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={composerNotice ? `${composerNotice.title}. ${composerNotice.description}` : "Message… (Enter to send, Shift+Enter for new line)"}
rows={1}
disabled={generating}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop}>■ Stop</button>
) : (
<button className="send-btn" onClick={handleSend} disabled={!input.trim() || Boolean(composerNotice)}>
Send ↑
</button>
)}
</div>
<div className="input-meta">
<span className="char-count">{input.length} chars</span>
</div>
</div>
</div>
);
}