feat: add SettingsPage with HuggingFace model search and fix flexbox layout
- Add SettingsPage component with macOS/Linear-style settings UI (sp-* classes) - Fix HuggingFace results list squeeze: sp-card-row--full now has align-items: stretch overriding the inherited flex-start, ensuring children fill 100% horizontal width - Fix sp-hf-file row layout: width 100%, grid columns for name/quant/size/download - Fix sp-hf-name truncation: explicit overflow/ellipsis/white-space in CSS - Add white-space: nowrap to sp-hf-size and sp-hf-quant-badge to protect secondary columns - Add runtime-deps crate, Tailwind/PostCSS config, runtime-defaults, MessageBubble test - Update Cargo workspace, inference-server, tauri runtime types and process handling
This commit is contained in:
1 parent
9da25c3688
commit
01b629cb4a
41 files changed
+6864
-822
No files matched your search
@@ -5,6 +5,7 @@ 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 "./ChatView.css";
|
||||
|
||||
const MAX_MEMORY = 20;
|
||||
@@ -31,37 +32,55 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
const [tps, setTps] = useState<number | null>(null);
|
||||
const [useStream, setUseStream] = useState(true);
|
||||
const [useMemory, setUseMemory] = useState(true);
|
||||
const [streamTokenCount, setStreamTokenCount] = useState(0);
|
||||
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
||||
const [showMemoryModal, setShowMemoryModal] = useState(false);
|
||||
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
||||
|
||||
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 messageQueueRef = useRef<string[]>([]);
|
||||
const sendRef = useRef<(text: string, fromChatId?: number) => Promise<void>>(async () => {});
|
||||
|
||||
// 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<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;
|
||||
const chatTotals = messages.reduce<ChatTokenTotals>((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;
|
||||
|
||||
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; }
|
||||
if (targetChatId == null) { setMessages([]); return; }
|
||||
const rows = db.query<Message>(
|
||||
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
|
||||
[targetChatId]
|
||||
[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]);
|
||||
@@ -69,18 +88,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
useEffect(() => {
|
||||
activeChatIdRef.current = chatId;
|
||||
|
||||
const switchedAwayFromPendingChat = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current;
|
||||
const switchedAway = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current;
|
||||
|
||||
if (abortRef.current && switchedAwayFromPendingChat) {
|
||||
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;
|
||||
}
|
||||
@@ -92,6 +113,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
setStreamThinkingOpen(false);
|
||||
setShowLiveReasoning(false);
|
||||
setTps(null);
|
||||
setStreamTokenCount(0);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
@@ -109,7 +131,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
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]);
|
||||
@@ -127,30 +148,27 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
const limit = useMemory ? MAX_MEMORY : 1;
|
||||
return 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]
|
||||
[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; }
|
||||
if (!text.trim() || generating) return;
|
||||
if (getComposerNotice(model, server)) return;
|
||||
|
||||
setGenerating(true);
|
||||
setStreamContent("");
|
||||
setStreamReasoning("");
|
||||
setStreamThinkingOpen(false);
|
||||
setShowLiveReasoning(false);
|
||||
setStreamTokenCount(0);
|
||||
|
||||
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);
|
||||
@@ -162,7 +180,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
|
||||
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",
|
||||
@@ -177,7 +194,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
}),
|
||||
});
|
||||
|
||||
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}`);
|
||||
@@ -188,14 +204,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
let tokenCount = 0;
|
||||
|
||||
if (useStream) {
|
||||
console.log("[ChatView] send: starting stream read");
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buf = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) { console.log(`[ChatView] send: stream done, total tokens received=${tokenCount}`); break; }
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop()!;
|
||||
@@ -203,12 +218,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
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; }
|
||||
if (data === "[DONE]") continue;
|
||||
try {
|
||||
const chunk = JSON.parse(data);
|
||||
const delta = chunk.choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
tokenCount++;
|
||||
setStreamTokenCount(tokenCount);
|
||||
fullContent += delta;
|
||||
const parsed = parseAssistantResponse(fullContent);
|
||||
setStreamContent(parsed.content);
|
||||
@@ -216,17 +232,14 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
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); }
|
||||
if (chunk.usage) usage = chunk.usage;
|
||||
} catch { /* ignore malformed chunks */ }
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -234,13 +247,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
if (parsed.reasoning) setShowLiveReasoning(true);
|
||||
}
|
||||
|
||||
// Save assistant message — only if there is real content to persist
|
||||
const parsed = parseAssistantResponse(fullContent);
|
||||
const finalContent = parsed.content.trim() || fullContent.trim();
|
||||
if (!finalContent && !parsed.reasoning) {
|
||||
console.warn("[ChatView] send: assistant returned empty content — not saving to DB");
|
||||
} else {
|
||||
console.log(`[ChatView] send: saving assistant message — finalContent.length=${finalContent.length}, reasoning.length=${parsed.reasoning?.length ?? 0}, usage=`, usage);
|
||||
if (finalContent || parsed.reasoning) {
|
||||
db.run(
|
||||
`INSERT INTO messages (
|
||||
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
|
||||
@@ -259,52 +268,58 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
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);
|
||||
setStreamTokenCount(0);
|
||||
|
||||
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;
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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 (textareaRef.current) textareaRef.current.style.height = "auto";
|
||||
|
||||
if (generating) {
|
||||
setQueue([...messageQueueRef.current, text]);
|
||||
return;
|
||||
}
|
||||
send(text);
|
||||
};
|
||||
|
||||
const handleStop = () => { abortRef.current?.abort(); };
|
||||
const handleStop = () => {
|
||||
abortRef.current?.abort();
|
||||
setQueue([]);
|
||||
};
|
||||
|
||||
const handleDeleteMessage = (id: number) => {
|
||||
if (!window.confirm("Delete this message?")) return;
|
||||
@@ -324,7 +339,11 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
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);
|
||||
if (generating) {
|
||||
setQueue([...messageQueueRef.current, newContent]);
|
||||
} else {
|
||||
send(newContent, msg.chat_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditAssistant = (id: number, newContent: string) => {
|
||||
@@ -332,68 +351,250 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
loadMessages();
|
||||
};
|
||||
|
||||
const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : [];
|
||||
|
||||
const modelShort = model.length > 30 ? model.slice(0, 28) + "…" : model;
|
||||
|
||||
return (
|
||||
<div className="chat-view">
|
||||
{/* Top bar */}
|
||||
{/* ── 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 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>
|
||||
{model && (
|
||||
<div className="topbar-model-badge">
|
||||
<div className={`topbar-status-dot${server ? " connected" : ""}`} />
|
||||
<span className="topbar-model-name">{modelShort}</span>
|
||||
</div>
|
||||
)}
|
||||
{tps !== null && (
|
||||
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
|
||||
</div>
|
||||
|
||||
<div className="chat-topbar-center">
|
||||
{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>
|
||||
|
||||
<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>
|
||||
{generating && streamTokenCount > 0 && (
|
||||
<div className="live-token-counter">
|
||||
<span className="live-token-pulse" />
|
||||
<span>{streamTokenCount.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
{/* ── Memory Modal ── */}
|
||||
{showMemoryModal && (
|
||||
<div className="memory-modal-overlay" onClick={() => setShowMemoryModal(false)}>
|
||||
<div className="memory-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="memory-modal-header">
|
||||
<div className="memory-modal-title">
|
||||
<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>
|
||||
<span>Memory Context</span>
|
||||
</div>
|
||||
<span className="memory-modal-subtitle">
|
||||
{memoryMessages.length} message{memoryMessages.length !== 1 ? "s" : ""} · {useMemory ? `last ${MAX_MEMORY}` : "single turn"} mode
|
||||
</span>
|
||||
<button type="button" className="memory-modal-close" onClick={() => setShowMemoryModal(false)}>✕</button>
|
||||
</div>
|
||||
<div className="memory-modal-body">
|
||||
{memoryMessages.length === 0 ? (
|
||||
<div className="memory-empty">No messages in context yet. Start a conversation to populate memory.</div>
|
||||
) : (
|
||||
memoryMessages.map((msg, i) => (
|
||||
<div key={i} className={`memory-item ${msg.role}`}>
|
||||
<span className="memory-item-role">{msg.role}</span>
|
||||
<span className="memory-item-content">
|
||||
{msg.content.length > 240 ? msg.content.slice(0, 238) + "…" : msg.content}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chat Layout ── */}
|
||||
<div className="chat-layout-root">
|
||||
{/* Scrollable message area */}
|
||||
<div className="chat-scroll-area">
|
||||
<div className="messages-container">
|
||||
{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">
|
||||
{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">
|
||||
<div className="msg-bubble">
|
||||
{(streamReasoning || streamThinkingOpen) && (
|
||||
<details
|
||||
className="msg-thinking"
|
||||
open={showLiveReasoning}
|
||||
onToggle={(event) => setShowLiveReasoning(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="thinking-summary" aria-label="Toggle live thinking details">
|
||||
<svg className="thinking-chevron" viewBox="0 0 16 16" focusable="false" aria-hidden="true">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<span className="reasoning-live-dot" aria-hidden="true" />
|
||||
Thinking{streamThinkingOpen ? "..." : ""}
|
||||
</summary>
|
||||
{streamReasoning && (
|
||||
<div
|
||||
className="thinking-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
|
||||
/>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
|
||||
{streamContent && (
|
||||
<div
|
||||
className="msg-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!streamContent && streamThinkingOpen && (
|
||||
<div className="thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
|
||||
<div className="msg-wrapper assistant">
|
||||
<div className="thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
<div className="scroll-spacer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Message Queue indicator ── */}
|
||||
{messageQueue.length > 0 && (
|
||||
<div className="queue-indicator">
|
||||
<span className="queue-indicator-pulse" />
|
||||
<span className="queue-indicator-text">
|
||||
{messageQueue.length} message{messageQueue.length > 1 ? "s" : ""} queued
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="queue-clear-btn"
|
||||
onClick={() => { messageQueueRef.current = []; setMessageQueue([]); }}
|
||||
>
|
||||
✕ Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fixed Input ── */}
|
||||
<div className="input-sticky-wrapper">
|
||||
<div className="input-gradient-mask" />
|
||||
<div className="input-content-constrained">
|
||||
<div className="input-wrapper">
|
||||
{composerNotice && (
|
||||
<div className={`composer-notice ${composerNotice.tone}`}>
|
||||
<div>
|
||||
@@ -407,107 +608,107 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
{/* Model capabilities — floating above the pill */}
|
||||
<div className="model-capabilities" aria-label="Model capabilities">
|
||||
<span className="capability-group">
|
||||
<span className="label">In</span>
|
||||
<ChatCircleText size={14} weight="duotone" />
|
||||
<Image size={14} weight="duotone" />
|
||||
</span>
|
||||
<span className="separator">·</span>
|
||||
<span className="capability-group">
|
||||
<span className="label">Out</span>
|
||||
<ChatCircleText size={14} weight="duotone" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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) }}
|
||||
/>
|
||||
{/* The typing pill */}
|
||||
<div className="input-box">
|
||||
<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>
|
||||
|
||||
<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…"
|
||||
: "Message..."
|
||||
}
|
||||
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"
|
||||
>
|
||||
<ArrowUp size={18} weight="duotone" />
|
||||
</button>
|
||||
)}
|
||||
</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 />
|
||||
{/* Queue system */}
|
||||
{messageQueue.length > 0 && (
|
||||
<div className="queue-container">
|
||||
{isQueueOpen && (
|
||||
<div className="queue-popover">
|
||||
<div className="queue-header">
|
||||
<span>Mensagens em espera</span>
|
||||
</div>
|
||||
<ul className="queue-list">
|
||||
{messageQueue.map((msg, index) => (
|
||||
<li key={index} className="queue-item">
|
||||
<span className="queue-text">{msg}</span>
|
||||
<button
|
||||
className="queue-remove"
|
||||
aria-label="Remover da fila"
|
||||
onClick={() => {
|
||||
const next = messageQueue.filter((_, i) => i !== index);
|
||||
setQueue(next);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="queue-toggle"
|
||||
onClick={() => setIsQueueOpen(!isQueueOpen)}
|
||||
>
|
||||
{messageQueue.length} queued
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
Reference in new issue
Block a user