feat(ui): complete Plan 06-01 - fix model scan + move model selector to input

- Fix Rust model scan to only list top-level models (not subdirectories)
- Add scan_model_dir_top_level() function in model_files.rs
- Create ModelSelectorPill component for chat input area
- Remove model-section from Sidebar, move to ChatView
- Update ChatView props to receive model-related props
- Update tests for new ChatView props
This commit is contained in:
Evandro Fonseca Junior committed 2026-04-02 09:06:06 -03:00
1 parent 3364dba5aa
commit cb722e79a6
11 files changed
+1695 -254

No files matched your search

+319 -84
View File
@@ -1,27 +1,46 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { DB } from "../lib/db";
import type { ChatTokenTotals, Message, Usage } from "../lib/types";
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 { ArrowUp, Image, Microphone, Paperclip, ChatCircleText } from "@phosphor-icons/react";
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, onChatCreated, onChatUpdated, onOpenSidebar }: Props) {
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<Message[]>([]);
const [input, setInput] = useState("");
const [generating, setGenerating] = useState(false);
@@ -44,7 +63,11 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const pendingRequestChatIdRef = useRef<number | null>(null);
const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {});
const messageQueueRef = useRef<string[]>([]);
const sendRef = useRef<(text: string, fromChatId?: number) => Promise<void>>(async () => {});
const sendRef = useRef<(text: string, fromChatId?: number, replaceAssistantId?: number) => Promise<void>>(async () => {});
// Tracks accumulated stream content so abort can persist it
const streamContentAccRef = useRef<string>("");
// TPS degradation detection: timer fires if TPS stays < 30 for 5s
const degradationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Single helper to keep ref and state in sync — use at every queue mutation site
const setQueue = (next: string[]) => {
@@ -70,8 +93,42 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)
: 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<Message>(
@@ -146,13 +203,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const buildApiMessages = useCallback((cid: number) => {
const limit = useMemory ? MAX_MEMORY : 1;
return db.query<{ role: string; content: string }>(
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();
}, [db, useMemory]);
const send = useCallback(async (text: string, fromChatId?: number) => {
// 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;
@@ -162,14 +226,22 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setStreamTokenCount(0);
streamContentAccRef.current = "";
const cid = fromChatId ?? getOrCreateChat(text);
activeChatIdRef.current = cid;
pendingRequestChatIdRef.current = cid;
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);
// 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();
@@ -226,6 +298,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
tokenCount++;
setStreamTokenCount(tokenCount);
fullContent += delta;
streamContentAccRef.current = fullContent;
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
@@ -239,6 +312,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
} 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);
@@ -269,8 +343,24 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
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") {
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}`,
@@ -284,6 +374,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setStreamTokenCount(0);
streamContentAccRef.current = "";
if (activeChatIdRef.current === cid) {
loadMessagesRef.current(cid);
@@ -298,7 +389,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setTimeout(() => sendRef.current(nextInQueue), 100);
}
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated]);
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories]);
// Keep sendRef always pointing to latest send closure
sendRef.current = send;
@@ -346,6 +437,31 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
}
};
/**
* 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<Message>(
`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();
@@ -353,10 +469,23 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : [];
const modelShort = model.length > 30 ? model.slice(0, 28) + "…" : model;
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 (
<div className="chat-view">
<div className={`chat-view${isInitialScreen ? " initial-screen" : ""}`}>
{/* ── Top bar ── */}
<div className="chat-topbar">
<div className="chat-topbar-left">
@@ -366,69 +495,81 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
</button>
)}
{model && (
<div className="topbar-model-badge">
<div className={`topbar-status-dot${server ? " connected" : ""}`} />
<div className={`topbar-model-badge${isInitialScreen ? " topbar-model-badge--hero" : ""}`}>
{!isInitialScreen && <div className={`topbar-status-dot${server ? " connected" : ""}`} />}
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
</div>
<div className="chat-topbar-center">
{chatId !== null && chatTotals.total_tokens > 0 && (
{isInitialScreen ? null : chatId !== null && chatTotals.total_tokens > 0 ? (
<div className="chat-token-summary">
<span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
<span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
<span title="Total tokens" className="token-total">Σ {chatTotals.total_tokens.toLocaleString()}</span>
</div>
) : (
<div className="chat-topbar-context" aria-label="Workspace status">
<span className="chat-topbar-context-label">{workspaceStateLabel}</span>
<span className="chat-topbar-context-copy">{workspaceStateCopy}</span>
</div>
)}
</div>
<div className="chat-topbar-right">
{generating && streamTokenCount > 0 && (
<span className={`topbar-state-pill${server ? " online" : " offline"}${isInitialScreen ? " topbar-state-pill--hero" : ""}`}>
{server ? "Runtime ready" : "Runtime offline"}
</span>
{!isInitialScreen && generating && streamTokenCount > 0 && (
<div className="live-token-counter">
<span className="live-token-pulse" />
<span>{streamTokenCount.toLocaleString()} tokens</span>
</div>
)}
{tps !== null && !generating && (
{!isInitialScreen && tps !== null && !generating && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
)}
<button
type="button"
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
onClick={() => setShowMemoryModal(!showMemoryModal)}
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
aria-label="View memory context"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
{memoryMessageCount > 0 && (
<span className="memory-count-badge">{memoryMessageCount}</span>
)}
</button>
{!isInitialScreen && (
<>
<button
type="button"
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
onClick={() => setShowMemoryModal(!showMemoryModal)}
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
aria-label="View memory context"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
{memoryMessageCount > 0 && (
<span className="memory-count-badge">{memoryMessageCount}</span>
)}
</button>
<div className="topbar-toggles">
<button
type="button"
className={`toggle-pill${useStream ? " on" : " off"}`}
onClick={() => setUseStream(!useStream)}
title={useStream ? "Streaming enabled" : "Streaming disabled"}
>
Stream
</button>
<button
type="button"
className={`toggle-pill${useMemory ? " on" : " off"}`}
onClick={() => setUseMemory(!useMemory)}
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
>
Memory
</button>
</div>
<div className="topbar-toggles">
<button
type="button"
className={`toggle-pill${useStream ? " on" : " off"}`}
onClick={() => setUseStream(!useStream)}
title={useStream ? "Streaming enabled" : "Streaming disabled"}
>
Stream
</button>
<button
type="button"
className={`toggle-pill${useMemory ? " on" : " off"}`}
onClick={() => setUseMemory(!useMemory)}
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
>
Memory
</button>
</div>
</>
)}
</div>
</div>
@@ -472,38 +613,118 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
<div className="chat-layout-root">
{/* Scrollable message area */}
<div className="chat-scroll-area">
<div className="messages-container">
<div className={`messages-container${isInitialScreen ? " messages-container--hero" : ""}`}>
{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 AI 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">
<div className="empty-state empty-state--hero">
<section className="empty-state-panel empty-state-panel--hero" aria-labelledby="workspace-welcome">
<div className="empty-state-header empty-state-header--hero">
<span className="empty-kicker">ForgeLM workspace</span>
<h1 id="workspace-welcome">What can I do for you?</h1>
</div>
<div className="hero-composer-block">
<div className="input-box input-box--hero">
<ModelSelectorPill
models={models}
localModels={localModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
onModelSwap={onModelSwap}
isSwapping={isSwapping}
status={status}
/>
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
<Microphone size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
<Paperclip size={20} weight="duotone" />
</button>
</div>
<div className="input-box-main">
<textarea
ref={textareaRef}
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}`
: generating
? "Type to queue next message…"
: "Assign a task or ask anything"
}
rows={1}
disabled={Boolean(composerNotice)}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop} type="button">
■ Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<PaperPlaneRight size={18} weight="fill" />
</button>
)}
</div>
<div className="composer-footer-row">
<span className="composer-footer-hint">
{composerNotice ? `${composerNotice.title} · ${composerNotice.description}` : "Connect your tools to ForgeLM"}
</span>
<div className="composer-footer-icons" aria-hidden="true">
<span />
<span />
<span />
<span />
<span />
</div>
</div>
</div>
</div>
<div className="starter-chip-rail" aria-label="Starter prompts">
{starterPrompts.map((prompt) => (
<button key={prompt} type="button" className="starter-chip" onClick={() => handleStarterPrompt(prompt)}>
<button key={prompt} type="button" className="starter-chip starter-chip--hero" 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 className="hero-card-carousel" aria-label="Workspace shortcuts">
{heroCards.map((card) => (
<button key={card.title} type="button" className="hero-card" onClick={() => handleStarterPrompt(card.copy)}>
<div>
<span className="hero-card-title">{card.title}</span>
<span className="hero-card-copy">{card.copy}</span>
</div>
<div className="hero-card-preview" aria-hidden="true">
<span />
<span />
<span />
</div>
</button>
))}
<div className="hero-card-pager" aria-hidden="true">
<span className="active" />
<span />
<span />
</div>
)}
</div>
</div>
</section>
</div>
)}
@@ -514,6 +735,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
onDelete={handleDeleteMessage}
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
onResend={msg.role === "assistant" ? handleResend : undefined}
renderMarkdown={renderMarkdown}
/>
))}
@@ -552,8 +774,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
<div className="thinking-manus">
<span className="thinking-dot" aria-hidden="true" />
<span className="thinking-label">Thinking</span>
</div>
)}
</div>
@@ -562,8 +785,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
<div className="thinking-manus">
<span className="thinking-dot" aria-hidden="true" />
<span className="thinking-label">Thinking</span>
</div>
</div>
)}
@@ -574,7 +798,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
</div>
{/* ── Message Queue indicator ── */}
{messageQueue.length > 0 && (
{!isInitialScreen && messageQueue.length > 0 && (
<div className="queue-indicator">
<span className="queue-indicator-pulse" />
<span className="queue-indicator-text">
@@ -591,6 +815,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)}
{/* ── Fixed Input ── */}
{!isInitialScreen && (
<div className="input-sticky-wrapper">
<div className="input-gradient-mask" />
<div className="input-content-constrained">
@@ -625,6 +850,15 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
{/* The typing pill */}
<div className="input-box">
<ModelSelectorPill
models={models}
localModels={localModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
onModelSwap={onModelSwap}
isSwapping={isSwapping}
status={status}
/>
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={20} weight="duotone" />
@@ -667,7 +901,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
type="button"
aria-label="Send message"
>
Send <ArrowUp size={18} weight="duotone" />
<PaperPlaneRight size={18} weight="fill" />
</button>
)}
</div>
@@ -710,6 +944,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
</div>
</div>
</div>
)}
</div>
</div>
);