feat: add Tauri desktop chat app with React + TypeScript
This commit is contained in:
1 parent
d8c5697f39
commit
ee3980436b
4132 files changed
+1163398
No files matched your search
@@ -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); } }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "&").replace(/'/g, "'");
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
Reference in new issue
Block a user