feat: add Tauri desktop chat app with React + TypeScript

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-26 21:05:41 -03:00
1 parent d8c5697f39
commit ee3980436b
4132 files changed
+1163398

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
.app-layout {
display: flex;
height: 100dvh;
overflow: hidden;
}
.app-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100dvh;
gap: 16px;
color: var(--text2);
font-size: 0.9rem;
}
.loading-spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
+85
View File
@@ -0,0 +1,85 @@
import { useState, useEffect, useCallback } from "react";
import Sidebar from "./components/Sidebar";
import ChatView from "./components/ChatView";
import { useDB } from "./hooks/useDB";
import { useModels } from "./hooks/useModels";
import type { Chat } from "./lib/types";
import "./App.css";
export default function App() {
const { db, ready } = useDB();
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
const [chats, setChats] = useState<Chat[]>([]);
const [server, setServer] = useState(() => localStorage.getItem("forgelm_server") || "http://127.0.0.1:8080");
const [apiKey, setApiKey] = useState(() => localStorage.getItem("forgelm_apikey") || "");
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(server, apiKey);
// Persist server/key
useEffect(() => { localStorage.setItem("forgelm_server", server); }, [server]);
useEffect(() => { localStorage.setItem("forgelm_apikey", apiKey); }, [apiKey]);
const loadChats = useCallback(() => {
if (!db) return;
const rows = db.query<Chat>("SELECT * FROM chats ORDER BY updated_at DESC");
setChats(rows);
}, [db]);
useEffect(() => { loadChats(); }, [loadChats]);
const newChat = useCallback(() => {
if (!db) return;
db.run("INSERT INTO chats (title) VALUES ('New Chat')");
const row = db.get<{ id: number }>("SELECT last_insert_rowid() as id");
if (row) {
setCurrentChatId(row.id);
loadChats();
}
}, [db, loadChats]);
const deleteChat = useCallback((id: number) => {
if (!db) return;
db.run("DELETE FROM chats WHERE id = ?", [id]);
if (currentChatId === id) setCurrentChatId(null);
loadChats();
}, [db, currentChatId, loadChats]);
if (!ready) {
return (
<div className="app-loading">
<div className="loading-spinner" />
<span>Initializing…</span>
</div>
);
}
return (
<div className="app-layout">
<Sidebar
chats={chats}
currentChatId={currentChatId}
onSelectChat={setCurrentChatId}
onNewChat={newChat}
onDeleteChat={deleteChat}
server={server}
onServerChange={setServer}
apiKey={apiKey}
onApiKeyChange={setApiKey}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
/>
<ChatView
db={db!}
chatId={currentChatId}
server={server}
apiKey={apiKey}
model={selectedModel}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); }}
onChatUpdated={loadChats}
/>
</div>
);
}
+281
View File
@@ -0,0 +1,281 @@
.chat-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
/* Top bar */
.chat-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 20px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
min-height: 44px;
}
.chat-topbar-left { display: flex; align-items: center; gap: 10px; }
.chat-topbar-right { display: flex; align-items: center; gap: 14px; }
.tps-badge {
font-size: 0.72rem;
background: var(--bg4);
border: 1px solid var(--border);
padding: 3px 10px;
border-radius: 20px;
color: var(--success);
font-variant-numeric: tabular-nums;
}
.toggle-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.78rem;
color: var(--text2);
cursor: pointer;
user-select: none;
}
.toggle-label input { cursor: pointer; accent-color: var(--accent); }
/* Messages */
.messages-area {
flex: 1;
overflow-y: auto;
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Empty state */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: var(--text2);
text-align: center;
padding: 40px;
}
.empty-icon { font-size: 2.5rem; }
.empty-state h2 { font-size: 1.4rem; color: var(--text); font-weight: 700; }
.empty-state p { font-size: 0.88rem; max-width: 380px; line-height: 1.6; }
/* Message wrappers */
.msg-wrapper {
display: flex;
flex-direction: column;
max-width: 780px;
width: 100%;
}
.msg-wrapper.user { align-self: flex-end; align-items: flex-end; }
.msg-wrapper.assistant { align-self: flex-start; align-items: flex-start; }
/* Bubbles */
.msg-bubble {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 0.9rem;
line-height: 1.7;
word-break: break-word;
}
.msg-wrapper.user .msg-bubble {
background: var(--user-bg);
border: 1px solid var(--user-border);
border-bottom-right-radius: 3px;
white-space: pre-wrap;
}
.msg-wrapper.assistant .msg-bubble {
background: var(--asst-bg);
border: 1px solid var(--asst-border);
border-bottom-left-radius: 3px;
}
/* Markdown inside bubbles */
.msg-bubble h1, .msg-bubble h2, .msg-bubble h3,
.msg-bubble h4, .msg-bubble h5, .msg-bubble h6 {
margin: 0.8em 0 0.3em;
line-height: 1.3;
font-weight: 600;
}
.msg-bubble h1 { font-size: 1.25em; }
.msg-bubble h2 { font-size: 1.1em; }
.msg-bubble h3 { font-size: 1em; }
.msg-bubble p { margin: 0.4em 0; }
.msg-bubble p:first-child { margin-top: 0; }
.msg-bubble p:last-child { margin-bottom: 0; }
.msg-bubble ul, .msg-bubble ol { padding-left: 1.4em; margin: 0.4em 0; }
.msg-bubble li { margin: 0.2em 0; }
.msg-bubble blockquote {
border-left: 3px solid var(--accent);
padding-left: 12px;
margin: 0.5em 0;
color: var(--text2);
font-style: italic;
}
.msg-bubble table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.85em; }
.msg-bubble th, .msg-bubble td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
.msg-bubble th { background: var(--bg3); font-weight: 600; }
.msg-bubble a { color: #7c9ef8; text-decoration: underline; }
.msg-bubble hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.msg-bubble strong { font-weight: 700; }
.msg-bubble em { font-style: italic; }
/* Inline code */
.msg-bubble code:not(.hljs) {
background: var(--code-bg);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 4px;
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.83em;
}
/* Code blocks */
.msg-bubble .code-block {
margin: 0.6em 0;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
}
.msg-bubble .code-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #0d0d10;
padding: 5px 12px;
font-size: 0.72rem;
}
.msg-bubble .code-lang { color: var(--text3); font-family: monospace; }
.msg-bubble .copy-code-btn {
background: none;
border: 1px solid var(--border2);
color: var(--text2);
border-radius: 4px;
padding: 2px 8px;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.msg-bubble .copy-code-btn:hover { background: var(--bg4); color: var(--text); }
.msg-bubble pre { margin: 0; overflow-x: auto; }
.msg-bubble pre code {
display: block;
padding: 12px 16px;
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.82rem;
line-height: 1.55;
}
/* Thinking */
.thinking {
display: flex;
gap: 5px;
padding: 14px 18px;
background: var(--asst-bg);
border: 1px solid var(--asst-border);
border-radius: var(--radius);
border-bottom-left-radius: 3px;
width: fit-content;
}
.thinking span {
width: 7px; height: 7px;
background: var(--text3);
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.thinking span:nth-child(2) { animation-delay: 0.2s; }
.thinking span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-5px); opacity: 1; }
}
/* Input area */
.input-area {
padding: 12px 20px 14px;
background: var(--bg2);
border-top: 1px solid var(--border);
}
.input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-textarea {
flex: 1;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
padding: 10px 14px;
resize: none;
min-height: 44px;
max-height: 200px;
outline: none;
line-height: 1.5;
transition: border-color 0.15s;
}
.input-textarea:focus { border-color: var(--accent); }
.input-textarea:disabled { opacity: 0.5; }
.send-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 10px 18px;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
height: 44px;
white-space: nowrap;
transition: background 0.15s, transform 0.1s;
font-family: inherit;
}
.send-btn:hover:not(:disabled) { background: var(--accent2); }
.send-btn:active:not(:disabled) { transform: scale(0.97); }
.send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.stop-btn {
background: var(--bg4);
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius);
padding: 10px 18px;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
height: 44px;
white-space: nowrap;
font-family: inherit;
transition: background 0.15s;
}
.stop-btn:hover { background: rgba(240,64,64,0.1); }
.input-meta {
display: flex;
align-items: center;
margin-top: 5px;
padding: 0 2px;
}
.char-count { font-size: 0.7rem; color: var(--text3); margin-left: auto; }
+294
View File
@@ -0,0 +1,294 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { DB } from "../lib/db";
import type { Message, Usage } from "../lib/types";
import { renderMarkdown, ensureHljs } from "../lib/markdown";
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;
}
export default function ChatView({ db, chatId, server, apiKey, model, onChatCreated, onChatUpdated }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [generating, setGenerating] = useState(false);
const [streamContent, setStreamContent] = useState<string | null>(null);
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);
useEffect(() => { ensureHljs(); }, []);
const loadMessages = useCallback(() => {
if (!chatId) { setMessages([]); return; }
const rows = db.query<Message>(
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[chatId]
);
setMessages(rows);
}, [db, chatId]);
useEffect(() => { loadMessages(); }, [loadMessages]);
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((): number => {
if (chatId) return chatId;
db.run("INSERT INTO chats (title) VALUES ('New Chat')");
const row = db.get<{ id: number }>("SELECT last_insert_rowid() as id");
const newId = row!.id;
onChatCreated(newId);
return newId;
}, [chatId, db, onChatCreated]);
const updateChatTitle = useCallback((cid: number, firstMsg: string) => {
const title = firstMsg.slice(0, 50) + (firstMsg.length > 50 ? "…" : "");
db.run("UPDATE chats SET title = ?, 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) => {
if (!text.trim() || generating) return;
if (!model) { alert("No model selected"); return; }
setGenerating(true);
setStreamContent(null);
const cid = fromChatId ?? getOrCreateChat();
// 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]);
if (count?.c === 1) updateChatTitle(cid, text);
loadMessages();
const apiMessages = buildApiMessages(cid);
const headers: Record<string, string> = { "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;
if (useStream) {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
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) { fullContent += delta; setStreamContent(fullContent); }
if (chunk.usage) usage = chunk.usage;
} catch {}
}
}
} else {
const data = await res.json();
fullContent = data.choices?.[0]?.message?.content || "";
usage = data.usage;
}
// Save assistant message
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [cid, fullContent]);
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
onChatUpdated();
} catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") {
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [
cid,
`⚠️ Error: ${err.message}`,
]);
}
} finally {
setGenerating(false);
setStreamContent(null);
loadMessages();
abortRef.current = null;
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, updateChatTitle, buildApiMessages, loadMessages, onChatUpdated]);
const handleSend = () => {
const text = input.trim();
if (!text) return;
setInput("");
if (textareaRef.current) { textareaRef.current.style.height = "auto"; }
send(text);
};
const handleStop = () => { abortRef.current?.abort(); };
const handleDeleteMessage = (id: number) => {
db.run("DELETE FROM messages WHERE id = ?", [id]);
loadMessages();
};
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">
{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 && messages.length === 0 && (
<div className="empty-state">
<div className="empty-icon">⚡</div>
<h2>ForgeLM Chat</h2>
<p>Start a conversation. Supports full Markdown, code highlighting, and persistent history.</p>
</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 && streamContent !== null && (
<div className="msg-wrapper assistant">
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
</div>
)}
{/* Thinking indicator */}
{generating && streamContent === null && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="input-area">
<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="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()}>
Send ↑
</button>
)}
</div>
<div className="input-meta">
<span className="char-count">{input.length} chars</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
/* Edit mode */
.msg-edit-container {
width: 100%;
max-width: 780px;
}
.msg-edit-textarea {
width: 100%;
background: var(--bg3);
border: 1px solid var(--accent);
border-radius: var(--radius);
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
padding: 10px 14px;
resize: none;
min-height: 80px;
outline: none;
line-height: 1.6;
}
.msg-edit-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.btn-primary {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: 6px 14px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
}
.btn-primary:hover { background: var(--accent2); }
.btn-secondary {
background: var(--bg4);
color: var(--text2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 14px;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
}
.btn-secondary:hover { background: var(--bg5); color: var(--text); }
.btn-ghost {
background: none;
color: var(--text3);
border: none;
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
transition: color 0.15s;
}
.btn-ghost:hover { color: var(--text2); }
/* Message actions */
.msg-actions {
display: flex;
gap: 4px;
margin-top: 5px;
opacity: 0;
transition: opacity 0.15s;
}
.msg-wrapper:hover .msg-actions { opacity: 1; }
.action-btn {
background: none;
border: 1px solid var(--border);
color: var(--text2);
border-radius: var(--radius-sm);
padding: 3px 9px;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
white-space: nowrap;
}
.action-btn:hover { background: var(--bg3); color: var(--text); border-color: var(--border2); }
.action-btn.copied { color: var(--success); border-color: var(--success); }
.action-btn.danger:hover { background: rgba(240,64,64,0.1); color: var(--danger); border-color: var(--danger); }
+122
View File
@@ -0,0 +1,122 @@
import { useState, useRef, useEffect } from "react";
import type { Message } from "../lib/types";
import "./MessageBubble.css";
interface Props {
message: Message;
onDelete: (id: number) => void;
onEditAndResend?: (msg: Message, newContent: string) => void;
onEditAssistant?: (id: number, newContent: string) => void;
renderMarkdown: (text: string) => string;
}
export default function MessageBubble({ message, onDelete, onEditAndResend, onEditAssistant, renderMarkdown }: Props) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const [copied, setCopied] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
// Wire up copy-code buttons inside rendered markdown
useEffect(() => {
if (!bubbleRef.current) return;
const buttons = bubbleRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
buttons.forEach((btn) => {
btn.onclick = () => {
const code = btn.getAttribute("data-code") || "";
navigator.clipboard.writeText(code).then(() => {
btn.textContent = "Copied!";
btn.style.color = "var(--success)";
setTimeout(() => { btn.textContent = "Copy"; btn.style.color = ""; }, 2000);
});
};
});
});
const autoResize = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 300) + "px";
};
const startEdit = () => {
setEditValue(message.content);
setEditing(true);
setTimeout(() => { textareaRef.current?.focus(); autoResize(); }, 0);
};
const cancelEdit = () => setEditing(false);
const saveEdit = (resend: boolean) => {
const trimmed = editValue.trim();
if (!trimmed) return;
setEditing(false);
if (resend && onEditAndResend) {
onEditAndResend(message, trimmed);
} else if (onEditAssistant) {
onEditAssistant(message.id, trimmed);
}
};
const copyMessage = () => {
navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className={`msg-wrapper ${message.role}`}>
{editing ? (
<div className="msg-edit-container">
<textarea
ref={textareaRef}
className="msg-edit-textarea"
value={editValue}
onChange={(e) => { setEditValue(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit();
if (e.key === "Enter" && e.ctrlKey) saveEdit(false);
}}
/>
<div className="msg-edit-actions">
{onEditAndResend && (
<button className="btn-primary" onClick={() => saveEdit(true)}>
Save & Resend
</button>
)}
<button className="btn-secondary" onClick={() => saveEdit(false)}>
Save only
</button>
<button className="btn-ghost" onClick={cancelEdit}>Cancel</button>
</div>
</div>
) : (
<>
{message.role === "assistant" ? (
<div
ref={bubbleRef}
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
) : (
<div className="msg-bubble">{message.content}</div>
)}
<div className="msg-actions">
<button className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage}>
{copied ? "✓ Copied" : "⎘ Copy"}
</button>
<button className="action-btn" onClick={startEdit}>
✎ Edit{onEditAndResend ? " & Resend" : ""}
</button>
<button className="action-btn danger" onClick={() => onDelete(message.id)}>
✕ Delete
</button>
</div>
</>
)}
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
.sidebar {
width: 260px;
min-width: 260px;
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.sidebar-header {
display: flex;
align-items: center;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--border);
gap: 8px;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.brand-icon { font-size: 1.1rem; }
.brand-name { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; }
.icon-btn {
background: none;
border: none;
color: var(--text2);
cursor: pointer;
font-size: 1rem;
padding: 4px 6px;
border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s;
line-height: 1;
}
.icon-btn:hover { background: var(--bg4); color: var(--text); }
/* Settings */
.settings-panel {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 6px;
background: var(--bg3);
}
.settings-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.settings-input {
background: var(--bg4);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 0.8rem;
padding: 6px 10px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
.settings-input:focus { border-color: var(--accent); }
/* Model section */
.model-section {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.model-row {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s;
}
.status-dot.status-ok { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.status-error { background: var(--danger); }
.status-dot.status-loading { background: var(--warning); animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.model-select {
flex: 1;
background: var(--bg4);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 0.78rem;
padding: 5px 8px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
}
.model-select:focus { border-color: var(--accent); }
/* Actions */
.sidebar-actions {
padding: 10px 14px 6px;
}
.new-chat-btn {
width: 100%;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s, transform 0.1s;
font-family: inherit;
}
.new-chat-btn:hover { background: var(--accent2); }
.new-chat-btn:active { transform: scale(0.98); }
/* Chat list */
.chat-list {
flex: 1;
overflow-y: auto;
padding: 6px 8px;
display: flex;
flex-direction: column;
gap: 1px;
}
.chat-list-empty {
text-align: center;
color: var(--text3);
font-size: 0.8rem;
padding: 20px 0;
}
.chat-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.82rem;
color: var(--text2);
transition: background 0.1s, color 0.1s;
position: relative;
}
.chat-item:hover { background: var(--bg3); color: var(--text); }
.chat-item.active { background: var(--bg4); color: var(--text); }
.chat-item-icon { font-size: 0.85rem; flex-shrink: 0; opacity: 0.6; }
.chat-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-item-del {
opacity: 0;
background: none;
border: none;
color: var(--danger);
cursor: pointer;
font-size: 0.75rem;
padding: 2px 5px;
border-radius: 4px;
line-height: 1;
transition: opacity 0.15s, background 0.15s;
flex-shrink: 0;
}
.chat-item:hover .chat-item-del { opacity: 1; }
.chat-item-del:hover { background: rgba(240,64,64,0.15); }
/* Footer */
.sidebar-footer {
padding: 10px 14px;
border-top: 1px solid var(--border);
}
.footer-version {
font-size: 0.7rem;
color: var(--text3);
}
+122
View File
@@ -0,0 +1,122 @@
import { useState } from "react";
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
import "./Sidebar.css";
interface Props {
chats: Chat[];
currentChatId: number | null;
onSelectChat: (id: number) => void;
onNewChat: () => void;
onDeleteChat: (id: number) => void;
server: string;
onServerChange: (v: string) => void;
apiKey: string;
onApiKeyChange: (v: string) => void;
models: ModelInfo[];
selectedModel: string;
onModelChange: (v: string) => void;
status: ServerStatus;
onRefreshModels: () => void;
}
export default function Sidebar({
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
server, onServerChange, apiKey, onApiKeyChange,
models, selectedModel, onModelChange, status, onRefreshModels,
}: Props) {
const [showSettings, setShowSettings] = useState(false);
return (
<aside className="sidebar">
{/* Header */}
<div className="sidebar-header">
<div className="sidebar-brand">
<span className="brand-icon">⚡</span>
<span className="brand-name">ForgeLM</span>
</div>
<button className="icon-btn" onClick={() => setShowSettings(!showSettings)} title="Settings">
⚙
</button>
</div>
{/* Settings panel */}
{showSettings && (
<div className="settings-panel">
<label className="settings-label">Server URL</label>
<input
className="settings-input"
type="text"
value={server}
onChange={(e) => onServerChange(e.target.value)}
onBlur={onRefreshModels}
placeholder="http://127.0.0.1:8080"
/>
<label className="settings-label">API Key (optional)</label>
<input
className="settings-input"
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="sk-…"
/>
</div>
)}
{/* Model selector */}
<div className="model-section">
<div className="model-row">
<div className={`status-dot status-${status}`} title={status} />
<select
className="model-select"
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
disabled={models.length === 0}
>
{models.length === 0 ? (
<option value="">No models</option>
) : (
models.map((m) => (
<option key={m.id} value={m.id}>{m.id}</option>
))
)}
</select>
<button className="icon-btn" onClick={onRefreshModels} title="Refresh models">↻</button>
</div>
</div>
{/* New chat button */}
<div className="sidebar-actions">
<button className="new-chat-btn" onClick={onNewChat}>
<span>+</span> New Chat
</button>
</div>
{/* Chat list */}
<nav className="chat-list">
{chats.length === 0 && (
<div className="chat-list-empty">No chats yet</div>
)}
{chats.map((chat) => (
<div
key={chat.id}
className={`chat-item ${chat.id === currentChatId ? "active" : ""}`}
onClick={() => onSelectChat(chat.id)}
>
<span className="chat-item-icon">💬</span>
<span className="chat-item-title">{chat.title}</span>
<button
className="chat-item-del"
onClick={(e) => { e.stopPropagation(); onDeleteChat(chat.id); }}
title="Delete"
>✕</button>
</div>
))}
</nav>
{/* Footer */}
<div className="sidebar-footer">
<span className="footer-version">ForgeLM Chat v0.1</span>
</div>
</aside>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useState, useEffect } from "react";
import { DB, initDB } from "../lib/db";
export function useDB() {
const [db, setDb] = useState<DB | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
initDB().then((instance) => {
setDb(instance);
setReady(true);
});
}, []);
return { db, ready };
}
+32
View File
@@ -0,0 +1,32 @@
import { useState, useEffect, useCallback } from "react";
import type { ModelInfo, ServerStatus } from "../lib/types";
export function useModels(server: string, apiKey: string) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [status, setStatus] = useState<ServerStatus>("loading");
const refresh = useCallback(async () => {
setStatus("loading");
try {
const headers: Record<string, string> = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${server}/v1/models`, { headers });
const data = await res.json();
const list: ModelInfo[] = data.data || [];
setModels(list);
if (list.length > 0 && !selectedModel) setSelectedModel(list[0].id);
setStatus("ok");
} catch {
setStatus("error");
}
}, [server, apiKey, selectedModel]);
useEffect(() => {
refresh();
const id = setInterval(refresh, 30_000);
return () => clearInterval(id);
}, [refresh]);
return { models, selectedModel, setSelectedModel, status, refresh };
}
+56
View File
@@ -0,0 +1,56 @@
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0c0c0e;
--bg2: #141416;
--bg3: #1c1c1f;
--bg4: #242428;
--bg5: #2c2c31;
--border: #2a2a30;
--border2: #38383f;
--text: #e8e8ed;
--text2: #8e8e99;
--text3: #5a5a66;
--accent: #5b6af0;
--accent2: #4a58e0;
--accent-glow: rgba(91,106,240,0.15);
--user-bg: #1a2040;
--user-border: #2a3560;
--asst-bg: #141416;
--asst-border: #2a2a30;
--danger: #f04040;
--success: #30d158;
--warning: #ffd60a;
--code-bg: #0d0d10;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--shadow: 0 2px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
font-size: 15px;
}
html, body, #root {
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg5); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border2); }
/* ── Selection ── */
::selection { background: var(--accent-glow); color: var(--text); }
/* ── Focus ring ── */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); }
/* ── Highlight.js theme override ── */
.hljs { background: var(--code-bg) !important; }
+109
View File
@@ -0,0 +1,109 @@
// Thin wrapper around sql.js loaded via CDN script tag
// We load sql.js dynamically to avoid bundling the WASM
declare global {
interface Window {
initSqlJs: (config: { locateFile: (f: string) => string }) => Promise<SqlJsStatic>;
}
}
interface SqlJsStatic {
Database: new (data?: ArrayBuffer | Uint8Array) => SqlDatabase;
}
interface SqlDatabase {
run(sql: string, params?: unknown[]): void;
prepare(sql: string): SqlStatement;
export(): Uint8Array;
close(): void;
}
interface SqlStatement {
bind(params?: unknown[]): void;
step(): boolean;
getAsObject(): Record<string, unknown>;
free(): void;
}
export class DB {
private inner: SqlDatabase;
constructor(inner: SqlDatabase) {
this.inner = inner;
}
run(sql: string, params: unknown[] = []) {
this.inner.run(sql, params);
this.persist();
}
query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] {
const stmt = this.inner.prepare(sql);
stmt.bind(params);
const rows: T[] = [];
while (stmt.step()) rows.push(stmt.getAsObject() as T);
stmt.free();
return rows;
}
get<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T | null {
return this.query<T>(sql, params)[0] ?? null;
}
persist() {
try {
const data = this.inner.export();
const b64 = btoa(String.fromCharCode(...data));
localStorage.setItem("forgelm_db_v2", b64);
} catch {}
}
}
export async function initDB(): Promise<DB> {
// Inject sql.js script if not already loaded
if (!window.initSqlJs) {
await new Promise<void>((resolve, reject) => {
const s = document.createElement("script");
s.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/sql-wasm.js";
s.onload = () => resolve();
s.onerror = reject;
document.head.appendChild(s);
});
}
const SQL = await window.initSqlJs({
locateFile: (f) => `https://cdn.jsdelivr.net/npm/[email protected]/dist/${f}`,
});
const saved = localStorage.getItem("forgelm_db_v2");
let inner: SqlDatabase;
if (saved) {
const buf = Uint8Array.from(atob(saved), (c) => c.charCodeAt(0));
inner = new SQL.Database(buf);
} else {
inner = new SQL.Database();
}
const db = new DB(inner);
db.run(`
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'New Chat',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_msg_chat ON messages(chat_id, created_at)`);
return db;
}
+73
View File
@@ -0,0 +1,73 @@
import { marked, Renderer } from "marked";
import DOMPurify from "dompurify";
// Dynamically load highlight.js languages
declare global {
interface Window {
hljs: {
highlight(code: string, opts: { language: string }): { value: string };
highlightAuto(code: string): { value: string };
getLanguage(lang: string): unknown;
};
}
}
let hlReady = false;
export async function ensureHljs() {
if (hlReady || window.hljs) { hlReady = true; return; }
const langs = ["javascript","typescript","python","rust","bash","json","sql","css","xml","go","cpp","c"];
const base = "https://cdn.jsdelivr.net/npm/highlight.js@11/lib";
await loadScript(`${base}/core.min.js`);
await Promise.all(langs.map((l) => loadScript(`${base}/languages/${l}.min.js`)));
hlReady = true;
}
function loadScript(src: string): Promise<void> {
return new Promise((res, rej) => {
if (document.querySelector(`script[src="${src}"]`)) { res(); return; }
const s = document.createElement("script");
s.src = src;
s.onload = () => res();
s.onerror = rej;
document.head.appendChild(s);
});
}
const renderer = new Renderer();
renderer.code = function (code: string, lang?: string) {
const language = lang && window.hljs?.getLanguage(lang) ? lang : "plaintext";
let highlighted = code;
try {
if (window.hljs) {
highlighted = language !== "plaintext"
? window.hljs.highlight(code, { language }).value
: window.hljs.highlightAuto(code).value;
}
} catch {}
// Escape for data attribute
const escaped = code.replace(/&/g, "&amp;").replace(/'/g, "&#39;");
return `<div class="code-block">
<div class="code-header">
<span class="code-lang">${language}</span>
<button class="copy-code-btn" data-code='${escaped}'>Copy</button>
</div>
<pre><code class="hljs language-${language}">${highlighted}</code></pre>
</div>`;
};
marked.setOptions({ breaks: true, gfm: true });
marked.use({ renderer });
export function renderMarkdown(text: string): string {
const html = marked.parse(text) as string;
return DOMPurify.sanitize(html, {
ADD_TAGS: ["div", "span", "button", "pre", "code"],
ADD_ATTR: ["class", "data-code"],
FORCE_BODY: false,
});
}
+30
View File
@@ -0,0 +1,30 @@
export interface Chat {
id: number;
title: string;
created_at: number;
updated_at: number;
}
export interface Message {
id: number;
chat_id: number;
role: "user" | "assistant" | "system";
content: string;
created_at: number;
}
export interface ModelInfo {
id: string;
object: string;
created: number;
owned_by: string;
}
export type ServerStatus = "ok" | "error" | "loading";
export interface Usage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
tokens_per_second?: number;
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);