Initial ForgeLM import

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

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
.app-layout {
display: flex;
height: 100dvh;
overflow: hidden;
position: relative;
}
.app-main {
flex: 1;
min-width: 0;
display: flex;
}
.app-sidebar-backdrop {
display: none;
}
.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); } }
@media (max-width: 960px) {
.app-layout {
background: var(--bg);
}
.app-main {
width: 100%;
}
.app-sidebar-backdrop {
display: block;
position: fixed;
inset: 0;
border: 0;
background: rgba(3, 8, 14, 0.72);
z-index: 19;
cursor: pointer;
}
}
+413
View File
@@ -0,0 +1,413 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
import type { DB } from "./lib/db";
import type { Message } from "./lib/types";
vi.mock("./hooks/useDB", () => ({
useDB: vi.fn(),
}));
vi.mock("./hooks/useModels", () => ({
useModels: vi.fn(() => ({
models: [],
selectedModel: "demo",
setSelectedModel: vi.fn(),
status: "ok",
refresh: vi.fn(),
})),
}));
vi.mock("./features/runtime/useRuntimeReadyModelRefresh", () => ({
useRuntimeReadyModelRefresh: vi.fn(),
}));
vi.mock("./features/runtime/useRuntimeManager", () => ({
useRuntimeManager: vi.fn(() => ({
snapshot: {
status: "ready",
process_running: false,
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "http://127.0.0.1:1234",
api_key: "",
model_path: "demo.gguf",
models_dir: "",
addr: "127.0.0.1:1234",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
hugging_face_token: "",
},
local: null,
remote: null,
last_error: null,
},
loading: false,
busy: false,
connection: {
serverUrl: "http://127.0.0.1:1234",
apiKey: "",
},
saveConfig: vi.fn(),
startLocal: vi.fn(),
stopLocal: vi.fn(),
})),
}));
import { useDB } from "./hooks/useDB";
function createDbStub(): DB {
const chats = [
{ id: 1, title: "Chat One", created_at: 1, updated_at: 2 },
{ id: 2, title: "Chat Two", created_at: 3, updated_at: 4 },
];
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "hello from one", created_at: 1 }]],
[2, [{ id: 21, chat_id: 2, role: "assistant", content: "hello from two", created_at: 2 }]],
]);
return {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM chats")) {
return chats;
}
if (sql.includes("FROM messages WHERE chat_id = ?")) {
return rowsByChat.get(Number(params?.[0])) ?? [];
}
return [];
}),
get: vi.fn(() => null),
run: vi.fn(),
persist: vi.fn(),
} as unknown as DB;
}
function createMutableDbStub(): DB {
const chats = [{ id: 1, title: "Existing chat", created_at: 1, updated_at: 1 }];
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "assistant", content: "old history", created_at: 1 }]],
]);
let nextChatId = 2;
let nextMessageId = 100;
let lastInsertRowId = 1;
return {
insert: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("INSERT INTO chats (title) VALUES ('New Chat')")) {
const id = nextChatId++;
lastInsertRowId = id;
chats.push({ id, title: "New Chat", created_at: id, updated_at: id });
rowsByChat.set(id, []);
return id;
}
if (sql.includes("INSERT INTO chats (title) VALUES (?)")) {
const id = nextChatId++;
const title = String(params?.[0] ?? "New Chat");
lastInsertRowId = id;
chats.push({ id, title, created_at: id, updated_at: id });
rowsByChat.set(id, []);
return id;
}
return lastInsertRowId;
}),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM chats")) {
return [...chats].sort((a, b) => b.updated_at - a.updated_at);
}
if (sql.includes("FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])];
}
if (sql.includes("SELECT role, content FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])].map(({ role, content }) => ({ role, content }));
}
return [];
}),
get: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("SELECT last_insert_rowid() as id")) {
return { id: 0 };
}
if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) {
return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 };
}
return null;
}),
run: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("INSERT INTO chats (title) VALUES ('New Chat')")) {
const id = nextChatId++;
lastInsertRowId = id;
chats.push({ id, title: "New Chat", created_at: id, updated_at: id });
rowsByChat.set(id, []);
return;
}
if (sql.includes("INSERT INTO chats (title) VALUES (?)")) {
const id = nextChatId++;
const title = String(params?.[0] ?? "New Chat");
lastInsertRowId = id;
chats.push({ id, title, created_at: id, updated_at: id });
rowsByChat.set(id, []);
return;
}
if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
return;
}
if (sql.includes("INSERT INTO messages (") && sql.includes("'assistant'")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
return;
}
if (sql.includes("UPDATE chats SET title = CASE WHEN title = 'New Chat'")) {
const title = String(params?.[0] ?? "New Chat");
const id = Number(params?.[1]);
const chat = chats.find((entry) => entry.id === id);
if (chat && (chat.title === "New Chat" || chat.title === "")) {
chat.title = title;
chat.updated_at = Date.now();
}
return;
}
if (sql.includes("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?")) {
const id = Number(params?.[0]);
const chat = chats.find((entry) => entry.id === id);
if (chat) {
chat.updated_at = Date.now();
}
}
}),
persist: vi.fn(),
} as unknown as DB;
}
describe("App chat history", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
Element.prototype.scrollIntoView = vi.fn();
vi.mocked(useDB).mockReturnValue({ db: createDbStub(), ready: true });
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("loads the saved chat messages after clicking a chat in the sidebar", async () => {
await act(async () => {
root.render(<App />);
});
const targetChat = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Chat Two"));
expect(targetChat).toBeTruthy();
await act(async () => {
targetChat?.click();
});
expect(container.textContent).toContain("hello from two");
expect(container.textContent).not.toContain("hello from one");
});
it("creates, selects, and keeps history for a newly sent chat", async () => {
const db = createMutableDbStub();
vi.mocked(useDB).mockReturnValue({ db, ready: true });
let resolveFetch: ((value: Response) => void) | null = null;
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
),
);
await act(async () => {
root.render(<App />);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
expect(setTextareaValue).toBeTruthy();
await act(async () => {
setTextareaValue?.call(textarea, "new conversation starts here");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
expect(sendButton).toBeTruthy();
await act(async () => {
sendButton?.click();
});
expect(container.textContent).toContain("new conversation starts here");
expect(container.textContent).toContain("new conversation starts here".slice(0, 40));
await act(async () => {
const encoder = new TextEncoder();
resolveFetch?.(
new Response(new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("data: {\"choices\":[{\"delta\":{\"content\":\"assistant reply from fresh chat\"}}]}\n"));
controller.enqueue(encoder.encode("data: {\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n"));
controller.enqueue(encoder.encode("data: [DONE]\n"));
controller.close();
},
}), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
);
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain("assistant reply from fresh chat");
expect(container.textContent).toContain("new conversation starts here");
const existingChat = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Existing chat"));
expect(existingChat).toBeTruthy();
await act(async () => {
existingChat?.click();
});
expect(container.textContent).toContain("old history");
const generatedChat = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("new conversation starts here"));
expect(generatedChat).toBeTruthy();
await act(async () => {
generatedChat?.click();
});
expect(container.textContent).toContain("assistant reply from fresh chat");
expect(container.textContent).toContain("new conversation starts here");
});
it("keeps history after creating a blank chat from the sidebar button", async () => {
const db = createMutableDbStub();
vi.mocked(useDB).mockReturnValue({ db, ready: true });
let resolveFetch: ((value: Response) => void) | null = null;
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
),
);
await act(async () => {
root.render(<App />);
});
const newChatButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("New Chat"));
expect(newChatButton).toBeTruthy();
await act(async () => {
newChatButton?.click();
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
expect(setTextareaValue).toBeTruthy();
await act(async () => {
setTextareaValue?.call(textarea, "message from sidebar-created chat");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
expect(sendButton).toBeTruthy();
await act(async () => {
sendButton?.click();
});
await act(async () => {
const encoder = new TextEncoder();
resolveFetch?.(
new Response(new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("data: {\"choices\":[{\"delta\":{\"content\":\"reply from sidebar-created chat\"}}]}\n"));
controller.enqueue(encoder.encode("data: {\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n"));
controller.enqueue(encoder.encode("data: [DONE]\n"));
controller.close();
},
}), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
);
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain("message from sidebar-created chat");
expect(container.textContent).toContain("reply from sidebar-created chat");
const existingChat = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Existing chat"));
expect(existingChat).toBeTruthy();
await act(async () => {
existingChat?.click();
});
expect(container.textContent).toContain("old history");
const generatedChat = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("message from sidebar-created chat"));
expect(generatedChat).toBeTruthy();
await act(async () => {
generatedChat?.click();
});
expect(container.textContent).toContain("message from sidebar-created chat");
expect(container.textContent).toContain("reply from sidebar-created chat");
});
});
+105
View File
@@ -0,0 +1,105 @@
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 { useRuntimeManager } from "./features/runtime/useRuntimeManager";
import { useRuntimeReadyModelRefresh } from "./features/runtime/useRuntimeReadyModelRefresh";
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 [sidebarOpen, setSidebarOpen] = useState(false);
const runtime = useRuntimeManager();
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
runtime.connection.serverUrl,
runtime.connection.apiKey,
Boolean(runtime.connection.serverUrl),
);
useRuntimeReadyModelRefresh(runtime.snapshot.status, refreshModels);
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;
const id = db.insert("INSERT INTO chats (title) VALUES ('New Chat')");
if (id > 0) {
setCurrentChatId(id);
setSidebarOpen(false);
loadChats();
}
}, [db, loadChats]);
const deleteChat = useCallback((id: number) => {
if (!db) return;
if (!window.confirm("Delete this conversation and all of its messages?")) return;
db.run("DELETE FROM chats WHERE id = ?", [id]);
if (currentChatId === id) setCurrentChatId(null);
setSidebarOpen(false);
loadChats();
}, [db, currentChatId, loadChats]);
const handleSelectChat = useCallback((id: number) => {
setCurrentChatId(id);
setSidebarOpen(false);
}, []);
if (!ready) {
return (
<div className="app-loading">
<div className="loading-spinner" />
<span>Initializing…</span>
</div>
);
}
return (
<div className={`app-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<Sidebar
chats={chats}
currentChatId={currentChatId}
onSelectChat={handleSelectChat}
onNewChat={newChat}
onDeleteChat={deleteChat}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
runtime={runtime}
onCloseMobile={() => setSidebarOpen(false)}
/>
{sidebarOpen && (
<button
type="button"
className="app-sidebar-backdrop"
aria-label="Close navigation"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className="app-main">
<ChatView
db={db!}
chatId={currentChatId}
server={runtime.connection.serverUrl}
apiKey={runtime.connection.apiKey}
model={selectedModel}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)}
/>
</div>
</div>
);
}
+479
View File
@@ -0,0 +1,479 @@
.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; }
.chat-menu-btn {
display: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg3);
color: var(--text);
cursor: pointer;
font-size: 1rem;
transition: background 0.15s, border-color 0.15s;
}
.chat-menu-btn:hover {
background: var(--bg4);
border-color: var(--border2);
}
.chat-token-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.72rem;
color: var(--text2);
font-variant-numeric: tabular-nums;
}
.chat-token-summary span {
background: var(--bg4);
border: 1px solid var(--border);
padding: 3px 10px;
border-radius: 20px;
}
.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;
color: var(--text2);
text-align: center;
padding: 40px;
}
.empty-state-panel {
width: min(100%, 680px);
padding: 32px;
border: 1px solid var(--border);
border-radius: 24px;
background:
radial-gradient(circle at top, rgba(72, 166, 255, 0.16), transparent 38%),
linear-gradient(180deg, rgba(20, 32, 45, 0.95), rgba(10, 16, 22, 0.96));
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.empty-icon { font-size: 2.5rem; }
.empty-kicker {
font-size: 0.74rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
.empty-state h2 { font-size: 1.8rem; color: var(--text); font-weight: 700; max-width: 14ch; }
.empty-state p { font-size: 0.92rem; max-width: 54ch; line-height: 1.7; }
.empty-state-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.starter-chip {
border: 1px solid var(--border2);
background: rgba(15, 23, 33, 0.85);
color: var(--text);
padding: 10px 14px;
border-radius: 999px;
cursor: pointer;
font: inherit;
font-size: 0.84rem;
transition: transform 0.12s ease, border-color 0.15s ease, background 0.15s ease;
}
.starter-chip:hover {
border-color: rgba(72, 166, 255, 0.5);
background: rgba(20, 32, 45, 0.98);
transform: translateY(-1px);
}
.composer-notice {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--border2);
background: rgba(20, 32, 45, 0.9);
}
.composer-notice strong {
display: block;
color: var(--text);
margin-bottom: 4px;
}
.composer-notice p {
color: var(--text2);
font-size: 0.84rem;
line-height: 1.5;
}
.composer-notice.warning {
border-color: rgba(255, 214, 10, 0.32);
background: rgba(72, 55, 4, 0.24);
}
.composer-notice.info {
border-color: rgba(72, 166, 255, 0.3);
background: rgba(16, 38, 63, 0.32);
}
.notice-action-btn {
border: 1px solid var(--border2);
background: var(--bg3);
color: var(--text);
border-radius: 12px;
padding: 10px 14px;
font: inherit;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.notice-action-btn:hover {
background: var(--bg4);
border-color: rgba(72, 166, 255, 0.45);
}
/* 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; }
}
.live-reasoning-panel {
margin-bottom: 8px;
}
/* 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; }
@media (max-width: 960px) {
.chat-menu-btn {
display: inline-flex;
}
.chat-topbar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.chat-topbar-left,
.chat-topbar-right {
width: 100%;
}
.chat-topbar-right {
justify-content: space-between;
flex-wrap: wrap;
}
.messages-area {
padding: 20px 14px;
}
.empty-state {
padding: 20px 14px;
}
.empty-state-panel {
padding: 24px 18px;
border-radius: 20px;
}
.empty-state h2 {
font-size: 1.45rem;
}
.composer-notice {
flex-direction: column;
align-items: flex-start;
}
.input-area {
padding: 12px 14px 16px;
}
.input-row {
flex-direction: column;
align-items: stretch;
}
.send-btn,
.stop-btn {
width: 100%;
}
}
+430
View File
@@ -0,0 +1,430 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DB } from "../lib/db";
import ChatView from "./ChatView";
import type { Message } from "../lib/types";
function createDbStub(): DB {
return {
insert: vi.fn(() => 1),
query: vi.fn(() => []),
get: vi.fn(() => null),
run: vi.fn(),
persist: vi.fn(),
} as unknown as DB;
}
describe("ChatView", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("keeps the draft in place when Enter is pressed before the runtime is ready", async () => {
const db = createDbStub();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={null}
server=""
apiKey=""
model=""
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
await act(async () => {
textarea!.value = "Keep this draft";
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
textarea!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(textarea?.value).toBe("Keep this draft");
expect(container.textContent).toContain("Connect an API");
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
});
it("loads messages when switching to a saved chat", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "First saved chat", created_at: 1 }]],
[2, [{ id: 21, chat_id: 2, role: "assistant", content: "Second saved chat", created_at: 2 }]],
]);
const db = {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM messages WHERE chat_id = ?")) {
const chatId = Number(params?.[0]);
return rowsByChat.get(chatId) ?? [];
}
return [];
}),
get: vi.fn(() => null),
run: vi.fn(),
persist: vi.fn(),
} as unknown as DB;
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("First saved chat");
await act(async () => {
root.render(
<ChatView
db={db}
chatId={2}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("Second saved chat");
expect(container.textContent).not.toContain("First saved chat");
});
it("keeps the selected saved chat visible when a previous request finishes later", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "History from first chat", created_at: 1 }]],
[2, [{ id: 21, chat_id: 2, role: "assistant", content: "Saved chat stays visible", created_at: 2 }]],
]);
let nextMessageId = 100;
const db = {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])];
}
return [];
}),
get: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) {
return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 };
}
return null;
}),
run: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
return;
}
if (sql.includes("INSERT INTO messages (") && sql.includes("'assistant'")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
}
}),
persist: vi.fn(),
} as unknown as DB;
let resolveFetch: ((value: Response) => void) | null = null;
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
),
);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
await act(async () => {
textarea!.value = "Send from first chat";
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
expect(sendButton).toBeTruthy();
await act(async () => {
sendButton?.click();
});
await act(async () => {
root.render(
<ChatView
db={db}
chatId={2}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("Saved chat stays visible");
await act(async () => {
resolveFetch?.(
new Response(
JSON.stringify({
choices: [{ message: { content: "Late answer from first chat" } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
await Promise.resolve();
});
expect(container.textContent).toContain("Saved chat stays visible");
expect(container.textContent).not.toContain("Late answer from first chat");
});
it("shows assistant reply after generation completes in an existing chat", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "Previous message", created_at: 1 }]],
]);
let nextMessageId = 200;
const db = {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])];
}
if (sql.includes("SELECT role, content FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])].map(({ role, content }) => ({ role, content }));
}
return [];
}),
get: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) {
return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 };
}
return null;
}),
run: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
}
if (sql.includes("INSERT INTO messages (") && sql.includes("'assistant'")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
}
}),
persist: vi.fn(),
} as unknown as DB;
let resolveFetch: ((value: Response) => void) | null = null;
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
),
);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("Previous message");
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
await act(async () => {
setTextareaValue?.call(textarea, "New question");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
await act(async () => { sendButton?.click(); });
// During generation, user message should be visible
expect(container.textContent).toContain("New question");
// Complete the generation
await act(async () => {
const encoder = new TextEncoder();
resolveFetch?.(
new Response(new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("data: {\"choices\":[{\"delta\":{\"content\":\"The answer is 42\"}}]}\n"));
controller.enqueue(encoder.encode("data: {\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":5,\"total_tokens\":10}}\n"));
controller.enqueue(encoder.encode("data: [DONE]\n"));
controller.close();
},
}), { status: 200, headers: { "Content-Type": "text/event-stream" } }),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
// After generation completes, both messages should still be visible
expect(container.textContent).toContain("New question");
expect(container.textContent).toContain("The answer is 42");
});
it("aborts an in-flight request when switching to another saved chat", async () => {
const db = createDbStub();
const signalRef: { current: { aborted: boolean } | null } = { current: null };
vi.stubGlobal(
"fetch",
vi.fn((_input, init?: RequestInit) => {
const signal = init?.signal as AbortSignal;
signalRef.current = signal;
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
});
}),
);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
expect(setTextareaValue).toBeTruthy();
await act(async () => {
setTextareaValue?.call(textarea, "Message in progress");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
expect(sendButton).toBeTruthy();
await act(async () => {
sendButton?.click();
});
expect(signalRef.current).toBeTruthy();
if (!signalRef.current) {
throw new Error("expected the request signal to be captured");
}
const requestSignal = signalRef.current;
expect(requestSignal.aborted).toBe(false);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={2}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
await Promise.resolve();
});
expect(requestSignal.aborted).toBe(true);
});
});
+511
View File
@@ -0,0 +1,511 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { DB } from "../lib/db";
import type { ChatTokenTotals, Message, Usage } from "../lib/types";
import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
import { renderMarkdown, ensureHljs } from "../lib/markdown";
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
import MessageBubble from "./MessageBubble";
import "./ChatView.css";
const MAX_MEMORY = 20;
interface Props {
db: DB;
chatId: number | null;
server: string;
apiKey: string;
model: string;
onChatCreated: (id: number) => void;
onChatUpdated: () => void;
onOpenSidebar?: () => void;
}
export default function ChatView({ db, chatId, server, apiKey, model, onChatCreated, onChatUpdated, onOpenSidebar }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [generating, setGenerating] = useState(false);
const [streamContent, setStreamContent] = useState("");
const [streamReasoning, setStreamReasoning] = useState("");
const [streamThinkingOpen, setStreamThinkingOpen] = useState(false);
const [showLiveReasoning, setShowLiveReasoning] = useState(false);
const [tps, setTps] = useState<number | null>(null);
const [useStream, setUseStream] = useState(true);
const [useMemory, setUseMemory] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const activeChatIdRef = useRef<number | null>(chatId);
const pendingRequestChatIdRef = useRef<number | null>(null);
const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {});
const composerNotice = getComposerNotice(model, server);
const starterPrompts = getSuggestedPrompts();
const chatTotals = messages.reduce<ChatTokenTotals>((totals, message) => {
totals.prompt_tokens += message.prompt_tokens ?? 0;
totals.completion_tokens += message.completion_tokens ?? 0;
totals.total_tokens += message.total_tokens ?? 0;
return totals;
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
useEffect(() => { ensureHljs(); }, []);
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
console.log(`[ChatView] loadMessages: chatId=${targetChatId}`);
if (targetChatId == null) { console.trace("[ChatView] loadMessages: no chatId — clearing messages, called from"); setMessages([]); return; }
const rows = db.query<Message>(
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[targetChatId]
);
console.log(`[ChatView] loadMessages: loaded ${rows.length} messages for chat ${targetChatId}`, rows.map(r => ({ id: r.id, role: r.role, contentLen: r.content?.length ?? 0 })));
console.trace(`[ChatView] loadMessages: setMessages(${rows.length}) called from`);
setMessages(rows);
}, [db, chatId]);
// Keep ref always pointing to latest loadMessages so async closures don't go stale
loadMessagesRef.current = loadMessages;
useEffect(() => { loadMessages(); }, [loadMessages]);
useEffect(() => {
activeChatIdRef.current = chatId;
const switchedAwayFromPendingChat = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current;
if (abortRef.current && switchedAwayFromPendingChat) {
abortRef.current.abort();
abortRef.current = null;
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setTps(null);
pendingRequestChatIdRef.current = null;
return;
}
if (!abortRef.current) {
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setTps(null);
}
}, [chatId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streamContent]);
const autoResize = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
};
const getOrCreateChat = useCallback((firstMessage?: string): number => {
if (chatId != null) return chatId;
const newId = db.insert("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]);
console.log(`[ChatView] getOrCreateChat: created chat id=${newId}`);
onChatCreated(newId);
return newId;
}, [chatId, db, onChatCreated]);
const ensureChatTitle = useCallback((cid: number, firstMsg: string) => {
const title = buildChatTitle(firstMsg);
db.run(
"UPDATE chats SET title = CASE WHEN title = 'New Chat' OR title = '' THEN ? ELSE title END, updated_at = strftime('%s','now') WHERE id = ?",
[title, cid],
);
onChatUpdated();
}, [db, onChatUpdated]);
const buildApiMessages = useCallback((cid: number) => {
const limit = useMemory ? MAX_MEMORY : 1;
return db.query<{ role: string; content: string }>(
"SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at DESC LIMIT ?",
[cid, limit]
).reverse();
}, [db, useMemory]);
const send = useCallback(async (text: string, fromChatId?: number) => {
console.log(`[ChatView] send: text="${text.slice(0, 60)}…" fromChatId=${fromChatId} generating=${generating} model=${model} server=${server}`);
if (!text.trim() || generating) { console.warn("[ChatView] send: blocked — empty text or already generating"); return; }
if (getComposerNotice(model, server)) { console.warn("[ChatView] send: blocked — composerNotice active"); return; }
setGenerating(true);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
const cid = fromChatId ?? getOrCreateChat(text);
console.log(`[ChatView] send: using chatId=${cid}`);
activeChatIdRef.current = cid;
pendingRequestChatIdRef.current = cid;
// Save user message
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]);
const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]);
console.log(`[ChatView] send: inserted user message, chat now has ${count?.c} messages`);
if (count?.c === 1) ensureChatTitle(cid, text);
loadMessagesRef.current(cid);
onChatUpdated();
const apiMessages = buildApiMessages(cid);
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
abortRef.current = new AbortController();
console.log(`[ChatView] send: fetching ${server}/v1/chat/completions — stream=${useStream}, messages=${apiMessages.length}`);
try {
const res = await fetch(`${server}/v1/chat/completions`, {
method: "POST",
headers,
signal: abortRef.current.signal,
body: JSON.stringify({
model,
messages: apiMessages,
stream: useStream,
stream_options: useStream ? { include_usage: true } : undefined,
max_tokens: 2048,
}),
});
console.log(`[ChatView] send: response status=${res.status} ok=${res.ok}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: { message?: string } })?.error?.message || `HTTP ${res.status}`);
}
let fullContent = "";
let usage: Usage | null = null;
let tokenCount = 0;
if (useStream) {
console.log("[ChatView] send: starting stream read");
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) { console.log(`[ChatView] send: stream done, total tokens received=${tokenCount}`); break; }
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop()!;
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") { console.log("[ChatView] send: received [DONE]"); continue; }
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
tokenCount++;
fullContent += delta;
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true);
}
if (chunk.usage) { usage = chunk.usage; console.log("[ChatView] send: received usage", usage); }
} catch (parseErr) { console.warn("[ChatView] send: failed to parse chunk", parseErr, data); }
}
}
console.log(`[ChatView] send: stream complete — fullContent.length=${fullContent.length}`);
} else {
console.log("[ChatView] send: reading blocking response");
const data = await res.json();
fullContent = data.choices?.[0]?.message?.content || "";
usage = data.usage;
console.log(`[ChatView] send: blocking response — fullContent.length=${fullContent.length}, usage=`, usage);
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true);
}
// Save assistant message
const parsed = parseAssistantResponse(fullContent);
const finalContent = parsed.content.trim() || fullContent.trim();
console.log(`[ChatView] send: saving assistant message — finalContent.length=${finalContent.length}, reasoning.length=${parsed.reasoning?.length ?? 0}, usage=`, usage);
db.run(
`INSERT INTO messages (
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`,
[
cid,
finalContent,
parsed.content.trim() ? parsed.reasoning : null,
usage?.prompt_tokens ?? 0,
usage?.completion_tokens ?? 0,
usage?.total_tokens ?? 0,
usage?.tokens_per_second ?? null,
],
);
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
onChatUpdated();
console.log(`[ChatView] send: assistant message saved to DB for chat ${cid}`);
} catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") {
console.error(`[ChatView] send: error — ${err.message}`, err);
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [
cid,
`⚠️ Error: ${err.message}`,
]);
onChatUpdated();
} else {
console.log("[ChatView] send: aborted");
}
} finally {
console.log(`[ChatView] send: finally — activeChatIdRef=${activeChatIdRef.current} cid=${cid}`);
// Direct DB check before reload
const dbCheck = db.query<{ id: number; role: string; content: string }>(
"SELECT id, role, substr(content,1,40) as content FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[cid]
);
console.log(`[ChatView] send: DB has ${dbCheck.length} messages for chat ${cid}:`, dbCheck);
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
if (activeChatIdRef.current === cid) {
console.log(`[ChatView] send: reloading messages for chat ${cid}`);
loadMessagesRef.current(cid);
} else {
console.warn(`[ChatView] send: skipping reload — active chat changed to ${activeChatIdRef.current}`);
}
pendingRequestChatIdRef.current = null;
abortRef.current = null;
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated]);
const handleSend = () => {
const text = input.trim();
if (!text || composerNotice) return;
setInput("");
if (textareaRef.current) { textareaRef.current.style.height = "auto"; }
send(text);
};
const handleStop = () => { abortRef.current?.abort(); };
const handleDeleteMessage = (id: number) => {
if (!window.confirm("Delete this message?")) return;
db.run("DELETE FROM messages WHERE id = ?", [id]);
loadMessages();
};
const handleStarterPrompt = (prompt: string) => {
setInput(prompt);
requestAnimationFrame(() => {
textareaRef.current?.focus();
autoResize();
});
};
const handleEditAndResend = (msg: Message, newContent: string) => {
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
db.run("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]);
loadMessages();
send(newContent, msg.chat_id);
};
const handleEditAssistant = (id: number, newContent: string) => {
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, id]);
loadMessages();
};
return (
<div className="chat-view">
{/* Top bar */}
<div className="chat-topbar">
<div className="chat-topbar-left">
{onOpenSidebar && (
<button
type="button"
className="chat-menu-btn"
onClick={onOpenSidebar}
aria-label="Open navigation"
>
☰
</button>
)}
{chatId !== null && chatTotals.total_tokens > 0 && (
<div className="chat-token-summary">
<span>Sent {chatTotals.prompt_tokens}</span>
<span>Generated {chatTotals.completion_tokens}</span>
<span>Total {chatTotals.total_tokens}</span>
</div>
)}
{tps !== null && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
)}
</div>
<div className="chat-topbar-right">
<label className="toggle-label">
<input type="checkbox" checked={useStream} onChange={(e) => setUseStream(e.target.checked)} />
Streaming
</label>
<label className="toggle-label">
<input type="checkbox" checked={useMemory} onChange={(e) => setUseMemory(e.target.checked)} />
Memory
</label>
</div>
</div>
{/* Messages */}
<div className="messages-area">
{chatId === null && messages.length === 0 && (
<div className="empty-state">
<div className="empty-state-panel">
<div className="empty-icon">⚡</div>
<span className="empty-kicker">Local-first coding workspace</span>
<h2>Start with a prompt that moves the project forward.</h2>
<p>
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
iterate, and debug without losing context.
</p>
<div className="empty-state-actions">
{starterPrompts.map((prompt) => (
<button
key={prompt}
type="button"
className="starter-chip"
onClick={() => handleStarterPrompt(prompt)}
>
{prompt}
</button>
))}
</div>
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
</div>
)}
</div>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onDelete={handleDeleteMessage}
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
renderMarkdown={renderMarkdown}
/>
))}
{/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
{(streamReasoning || streamThinkingOpen) && (
<div className="reasoning-panel live-reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowLiveReasoning((value) => !value)}
type="button"
>
<span>{showLiveReasoning ? "▼" : "▶"}</span>
<span>{streamThinkingOpen ? "Thinking..." : "Thinking"}</span>
</button>
{showLiveReasoning && streamReasoning && (
<div
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
/>
)}
</div>
)}
{streamContent && (
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
</div>
)}
</div>
)}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="input-area">
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
</div>
)}
<div className="input-row">
<textarea
ref={textareaRef}
className="input-textarea"
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={composerNotice ? `${composerNotice.title}. ${composerNotice.description}` : "Message… (Enter to send, Shift+Enter for new line)"}
rows={1}
disabled={generating}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop}>■ Stop</button>
) : (
<button className="send-btn" onClick={handleSend} disabled={!input.trim() || Boolean(composerNotice)}>
Send ↑
</button>
)}
</div>
<div className="input-meta">
<span className="char-count">{input.length} chars</span>
</div>
</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
/* 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 */
.reasoning-panel {
width: 100%;
margin-bottom: 8px;
}
.reasoning-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(124, 158, 248, 0.08);
color: var(--text2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
}
.reasoning-toggle:hover {
background: rgba(124, 158, 248, 0.12);
color: var(--text);
}
.reasoning-content {
margin-top: 8px;
padding: 12px 14px;
background: rgba(124, 158, 248, 0.06);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text2);
font-size: 0.84rem;
line-height: 1.6;
}
.message-metrics {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
color: var(--text3);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
.message-metrics span {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
}
.msg-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 5px;
opacity: 0;
transition: opacity 0.15s;
}
.msg-wrapper:hover .msg-actions { opacity: 1; }
.msg-wrapper:focus-within .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); }
@media (hover: none), (max-width: 960px) {
.msg-actions {
opacity: 1;
}
}
+175
View File
@@ -0,0 +1,175 @@
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 [showReasoning, setShowReasoning] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const reasoningRef = 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);
});
};
});
});
useEffect(() => {
if (!reasoningRef.current) return;
const buttons = reasoningRef.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);
});
};
const hasReasoning = Boolean(message.reasoning_content?.trim());
const hasUsage = message.role === "assistant" && (
(message.prompt_tokens ?? 0) > 0 ||
(message.completion_tokens ?? 0) > 0 ||
(message.total_tokens ?? 0) > 0
);
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>
) : (
<>
{hasReasoning && (
<div className="reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowReasoning((value) => !value)}
type="button"
>
<span>{showReasoning ? "▼" : "▶"}</span>
<span>Thinking</span>
</button>
{showReasoning && (
<div
ref={reasoningRef}
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
/>
)}
</div>
)}
{message.role === "assistant" ? (
<div
ref={bubbleRef}
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
) : (
<div className="msg-bubble">{message.content}</div>
)}
{hasUsage && (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
<span>Generated {message.completion_tokens ?? 0}</span>
<span>Total {message.total_tokens ?? 0}</span>
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
</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>
);
}
+409
View File
@@ -0,0 +1,409 @@
.sidebar {
width: 280px;
min-width: 280px;
background:
radial-gradient(circle at top left, rgba(72, 166, 255, 0.12), transparent 26%),
var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-close-btn {
display: none;
}
/* ── Header ── */
.sidebar-header {
display: flex;
align-items: center;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
min-width: 0;
}
.brand-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, #2b88e6, #49c0ff);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(91,106,240,0.35);
}
.brand-name {
font-size: 15px;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #f5fbff, #89b5d9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.icon-btn {
background: none;
border: none;
color: var(--text3);
cursor: pointer;
font-size: 15px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
}
.icon-btn:hover {
background: var(--bg4);
color: var(--text);
}
/* ── Settings panel ── */
.settings-panel {
padding: 10px 12px 12px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 5px;
background: var(--bg3);
animation: slide-down 0.15s ease;
flex-shrink: 0;
}
@keyframes slide-down {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.settings-label {
font-size: 10px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 4px;
}
.settings-input {
background: var(--bg4);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
padding: 7px 10px;
outline: none;
transition: border-color 0.15s, background 0.15s;
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.settings-input:focus {
border-color: var(--accent);
background: var(--bg5);
}
/* ── Model section ── */
.model-section {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.model-section-label {
font-size: 10px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.model-row {
display: flex;
align-items: center;
gap: 7px;
}
.model-help {
margin-top: 8px;
font-size: 11px;
color: var(--text3);
line-height: 1.5;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s, box-shadow 0.3s;
}
.status-dot.status-ok {
background: var(--success);
box-shadow: 0 0 6px rgba(48,209,88,0.5);
}
.status-dot.status-error {
background: var(--danger);
}
.status-dot.status-loading {
background: var(--warning);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.model-select {
flex: 1;
background: var(--bg4);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
padding: 6px 28px 6px 9px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
transition: border-color 0.15s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.model-select:focus {
border-color: var(--accent);
}
/* ── Sidebar actions ── */
.sidebar-actions {
padding: 10px 12px 6px;
flex-shrink: 0;
}
.new-chat-btn {
width: 100%;
background: linear-gradient(135deg, #2b88e6, #49c0ff);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: 9px 12px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
font-family: inherit;
letter-spacing: 0.01em;
box-shadow: 0 2px 10px rgba(72,166,255,0.25);
}
.new-chat-btn:hover {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(72,166,255,0.4);
}
.new-chat-btn:active {
transform: scale(0.98);
}
/* ── Chat list ── */
.chat-list-section-label {
font-size: 10px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 12px 4px;
flex-shrink: 0;
}
.chat-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 8px;
display: flex;
flex-direction: column;
gap: 1px;
}
.chat-list-empty {
text-align: center;
color: var(--text3);
font-size: 12px;
padding: 24px 0;
line-height: 1.6;
}
.chat-item {
display: flex;
align-items: center;
gap: 4px;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text2);
transition: background 0.12s, color 0.12s;
position: relative;
border-left: 2px solid transparent;
}
.chat-item-button {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
}
.chat-item:hover {
background: var(--bg3);
color: var(--text);
}
.chat-item:focus-within {
background: var(--bg3);
color: var(--text);
}
.chat-item.active {
background: rgba(91,106,240,0.1);
color: var(--text);
border-left-color: var(--accent);
}
.chat-item-icon {
font-size: 13px;
flex-shrink: 0;
opacity: 0.5;
}
.chat-item.active .chat-item-icon {
opacity: 0.8;
}
.chat-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.chat-item-del {
opacity: 0;
background: none;
border: none;
color: var(--text3);
cursor: pointer;
font-size: 12px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: opacity 0.15s, background 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
margin-right: 6px;
}
.chat-item:hover .chat-item-del {
opacity: 1;
}
.chat-item:focus-within .chat-item-del,
.chat-item.active .chat-item-del {
opacity: 1;
}
.chat-item-del:hover {
background: rgba(240,64,64,0.15);
color: var(--danger);
}
/* ── Footer ── */
.sidebar-footer {
padding: 8px 14px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.footer-version {
font-size: 10px;
color: var(--text3);
letter-spacing: 0.02em;
}
@media (max-width: 960px) {
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: min(86vw, 320px);
min-width: 0;
z-index: 20;
transform: translateX(-100%);
transition: transform 0.22s ease, box-shadow 0.22s ease;
box-shadow: none;
}
.sidebar-open .sidebar {
transform: translateX(0);
box-shadow: var(--shadow-lg);
}
.sidebar-close-btn {
display: inline-flex;
}
}
@media (hover: none), (max-width: 960px) {
.chat-item-del {
opacity: 1;
}
}
+175
View File
@@ -0,0 +1,175 @@
import { useState } from "react";
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
import RuntimeWorkspace from "../features/runtime/components/RuntimeWorkspace";
import type { RuntimeConfig, RuntimeDraftFieldMeta, RuntimeDraftFieldState, RuntimeSnapshot } from "../features/runtime/types";
import "./Sidebar.css";
interface Props {
chats: Chat[];
currentChatId: number | null;
onSelectChat: (id: number) => void;
onNewChat: () => void;
onDeleteChat: (id: number) => void;
models: ModelInfo[];
selectedModel: string;
onModelChange: (v: string) => void;
status: ServerStatus;
onRefreshModels: () => void;
runtime: {
snapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
draftDirty: boolean;
draftOpen: boolean;
draftFields: RuntimeDraftFieldState;
loading: boolean;
busy: boolean;
activeLifecycleCommand: RuntimeSnapshot["active_command"];
lastLifecycleResult: RuntimeSnapshot["last_command_result"];
lifecycleBusy: boolean;
applyImpact: "immediate" | "restart_required" | "save_before_apply";
openDraft: () => void;
closeDraft: () => void;
resetDraft: () => void;
updateDraft: (patch: Partial<RuntimeConfig>) => void;
updateDraftFieldMeta: (field: keyof RuntimeDraftFieldState & string, patch: Partial<RuntimeDraftFieldMeta>) => void;
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
startLocal: () => Promise<unknown>;
stopLocal: () => Promise<unknown>;
};
onCloseMobile: () => void;
}
export default function Sidebar({
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
models, selectedModel, onModelChange, status, onRefreshModels,
runtime,
onCloseMobile,
}: Props) {
const modelCountLabel = models.length === 1 ? "1 model available" : `${models.length} models available`;
return (
<>
<aside className="sidebar">
{/* ── Header ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="brand-icon">⚡</div>
<span className="brand-name">ForgeLM</span>
</div>
<div className="sidebar-header-actions">
<button
className="icon-btn"
onClick={() => {
runtime.openDraft();
}}
title="Runtime settings"
aria-label="Open runtime settings"
type="button"
>
⚙
</button>
<button
className="icon-btn sidebar-close-btn"
onClick={onCloseMobile}
title="Close navigation"
aria-label="Close navigation"
type="button"
>
✕
</button>
</div>
</div>
{/* ── Unified Runtime Workspace ── */}
<RuntimeWorkspace runtime={runtime} />
{/* ── Active model selector ── */}
<div className="model-section">
<div className="model-section-label">Active model</div>
<div className="model-row">
<div className={`status-dot status-${status}`} title={status} aria-hidden="true" />
<select
className="model-select"
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
disabled={models.length === 0}
title={models.length === 0 ? "Start the server to load models" : ""}
aria-label="Choose active model"
>
{models.length === 0 ? (
<option value="">Start server to load 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"
aria-label="Refresh models"
type="button"
>
↻
</button>
</div>
<div className="model-help">
{models.length === 0 ? "Start the runtime to load available models." : modelCountLabel}
</div>
</div>
{/* ── New chat ── */}
<div className="sidebar-actions">
<button className="new-chat-btn" onClick={onNewChat} type="button">
<span>+</span> New Chat
</button>
</div>
{/* ── Chat list ── */}
{chats.length > 0 && (
<div className="chat-list-section-label">Conversations</div>
)}
<nav className="chat-list" aria-label="Conversation history">
{chats.length === 0 && (
<div className="chat-list-empty">
No conversations yet.<br />Start a new chat above.
</div>
)}
{chats.map((chat) => (
<div
key={chat.id}
className={`chat-item ${chat.id === currentChatId ? "active" : ""}`}
>
<button
type="button"
className="chat-item-button"
onClick={() => onSelectChat(chat.id)}
aria-current={chat.id === currentChatId ? "page" : undefined}
>
<span className="chat-item-icon">💬</span>
<span className="chat-item-title">{chat.title}</span>
</button>
<button
type="button"
className="chat-item-del"
onClick={() => onDeleteChat(chat.id)}
title="Delete conversation"
aria-label={`Delete conversation ${chat.title}`}
>
✕
</button>
</div>
))}
</nav>
{/* ── Footer ── */}
<div className="sidebar-footer">
<span className="footer-version">ForgeLM v0.1.0</span>
</div>
</aside>
</>
);
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
describe("chat view state helpers", () => {
it("asks for an API endpoint before sending when no server is configured", () => {
expect(getComposerNotice("model.gguf", "")).toMatchObject({
tone: "warning",
title: "Connect an API",
actionLabel: "Show runtime panel",
});
});
it("asks for a model after the runtime is connected", () => {
expect(getComposerNotice("", "http://127.0.0.1:8080")).toMatchObject({
tone: "info",
title: "Choose a model",
actionLabel: "Show runtime panel",
});
});
it("clears the composer notice when the workspace is ready", () => {
expect(getComposerNotice("model.gguf", "http://127.0.0.1:8080")).toBeNull();
});
it("offers starter prompts tailored to the local-first workspace", () => {
expect(getSuggestedPrompts()).toEqual([
"Summarize this repository architecture.",
"Help me debug why the local runtime is not ready.",
"Draft a cleaner system prompt for this coding workflow.",
]);
});
});
@@ -0,0 +1,38 @@
export interface ComposerNotice {
tone: "info" | "warning";
title: string;
description: string;
actionLabel: string;
}
const SUGGESTED_PROMPTS = [
"Summarize this repository architecture.",
"Help me debug why the local runtime is not ready.",
"Draft a cleaner system prompt for this coding workflow.",
] as const;
export function getSuggestedPrompts(): string[] {
return [...SUGGESTED_PROMPTS];
}
export function getComposerNotice(model: string, server: string): ComposerNotice | null {
if (!server.trim()) {
return {
tone: "warning",
title: "Connect an API",
description: "Open runtime settings and configure a local or remote endpoint before sending messages.",
actionLabel: "Show runtime panel",
};
}
if (!model.trim()) {
return {
tone: "info",
title: "Choose a model",
description: "Start the runtime or refresh the model list, then select the model you want to chat with.",
actionLabel: "Show runtime panel",
};
}
return null;
}
+151
View File
@@ -0,0 +1,151 @@
import { invoke } from "@tauri-apps/api/core";
import { createDefaultRuntimeSnapshot, createRuntimeLifecycleResult } from "./runtime-utils";
import type {
ModelLibraryState,
HuggingFaceModelResult,
RuntimeConfig,
RuntimeGpuInfo,
RuntimeModelFile,
RuntimePickDirResult,
RuntimeSnapshot,
} from "./types";
const STORAGE_KEY = "forgelm_runtime_snapshot_v1";
function canUseLocalStorage(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function readFallbackSnapshot(): RuntimeSnapshot {
if (!canUseLocalStorage()) {
return createDefaultRuntimeSnapshot();
}
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return createDefaultRuntimeSnapshot();
}
try {
return { ...createDefaultRuntimeSnapshot(), ...JSON.parse(raw) } as RuntimeSnapshot;
} catch {
return createDefaultRuntimeSnapshot();
}
}
function writeFallbackSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
if (canUseLocalStorage()) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
}
return snapshot;
}
export async function bootstrapRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("bootstrap_runtime_command");
} catch {
return readFallbackSnapshot();
}
}
export async function getRuntimeSnapshot(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("get_runtime_snapshot_command");
} catch {
return readFallbackSnapshot();
}
}
export async function saveRuntimeConfig(config: RuntimeConfig): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("save_runtime_config_command_handler", { config });
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({
...snapshot,
active_command: "idle",
last_command_result: createRuntimeLifecycleResult("save", "Save completed."),
config,
});
}
}
export async function startRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("start_runtime_command");
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({
...snapshot,
status: "error",
active_command: "idle",
last_command_result: createRuntimeLifecycleResult(
"start",
"Local runtime control requires the Tauri desktop app.",
"failed",
),
last_error: { code: "tauri_unavailable", message: "Local runtime control requires the Tauri desktop app." },
});
}
}
export async function stopRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("stop_runtime_command");
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({
...snapshot,
status: "stopped",
process_running: false,
ready: false,
active_command: "idle",
last_command_result: createRuntimeLifecycleResult("stop", "Local API stopped."),
});
}
}
export async function queryRuntimeGpu(): Promise<RuntimeGpuInfo | null> {
try {
return await invoke<RuntimeGpuInfo>("query_gpu");
} catch {
return null;
}
}
export async function scanRuntimeModels(dir: string): Promise<RuntimeModelFile[]> {
return invoke<RuntimeModelFile[]>("scan_models", { dir });
}
export async function pickRuntimeModelsDir(): Promise<RuntimePickDirResult | null> {
try {
return await invoke<RuntimePickDirResult>("pick_models_dir");
} catch (error) {
if (String(error) === "cancelled") {
return null;
}
throw error;
}
}
export async function pickRuntimeModelFiles(): Promise<RuntimeModelFile[]> {
try {
return await invoke<RuntimeModelFile[]>("pick_model_files");
} catch (error) {
if (String(error) === "cancelled") {
return [];
}
throw error;
}
}
export async function searchHuggingFaceModels(query: string, token = ""): Promise<HuggingFaceModelResult[]> {
return invoke<HuggingFaceModelResult[]>("search_hugging_face_models_command", {
query,
token: token.trim() || null,
});
}
export async function getModelLibraryState(): Promise<ModelLibraryState> {
return invoke<ModelLibraryState>("get_model_library_state_command");
}
@@ -0,0 +1,606 @@
.runtime-drawer-shell {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
justify-content: flex-end;
}
.runtime-drawer-backdrop {
flex: 1;
border: 0;
background: rgba(5, 6, 10, 0.58);
backdrop-filter: blur(6px);
}
.runtime-drawer {
width: min(560px, 100%);
height: 100%;
display: flex;
flex-direction: column;
background:
radial-gradient(circle at top right, rgba(72, 166, 255, 0.14), transparent 24%),
linear-gradient(180deg, #10161f, #0b1017 72%);
border-left: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: -28px 0 70px rgba(0, 0, 0, 0.48);
}
.runtime-drawer-header,
.runtime-drawer-footer {
padding: 20px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.runtime-drawer-header-sticky,
.runtime-drawer-footer-sticky {
position: sticky;
z-index: 2;
backdrop-filter: blur(16px);
background: rgba(10, 16, 22, 0.92);
}
.runtime-drawer-header-sticky {
top: 0;
}
.runtime-drawer-footer-sticky {
bottom: 0;
}
.runtime-drawer-footer {
border-bottom: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 10px;
}
.runtime-drawer-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #78bdf4;
margin-bottom: 6px;
}
.runtime-drawer-header h2 {
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.03em;
}
.runtime-drawer-copy {
margin-top: 10px;
max-width: 34ch;
color: var(--text2);
line-height: 1.55;
}
.runtime-drawer-summary-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: 14px;
}
.runtime-drawer-summary-badge,
.runtime-live-chip,
.runtime-live-chip-value,
.runtime-field-badge {
border-radius: 999px;
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
}
.runtime-drawer-summary-badge,
.runtime-live-chip-value {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text2);
}
.runtime-drawer-summary-badge.is-dirty,
.runtime-field-badge {
border: 1px solid rgba(72, 166, 255, 0.45);
background: rgba(72, 166, 255, 0.16);
color: #9cd2ff;
}
.runtime-live-chip {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text3);
}
.runtime-field-badge-danger {
border-color: rgba(240, 64, 64, 0.45);
background: rgba(240, 64, 64, 0.14);
color: #ffb7b7;
}
.runtime-field-badge-pending {
border-color: rgba(243, 211, 139, 0.32);
background: rgba(243, 211, 139, 0.12);
color: #f3d38b;
}
.runtime-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-drawer-close {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-size: 26px;
line-height: 1;
cursor: pointer;
}
.runtime-drawer-body {
flex: 1;
overflow-y: auto;
padding: 20px 22px 28px;
}
.runtime-drawer-section {
margin-bottom: 18px;
}
.runtime-section-card,
.runtime-overview-card {
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.runtime-section-card {
padding: 16px;
}
.runtime-overview-card {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 18px;
margin-bottom: 18px;
}
.runtime-overview-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 8px;
}
.runtime-overview-value {
color: var(--text);
font-size: 14px;
line-height: 1.45;
}
.runtime-overview-mono,
.runtime-static-value-mono {
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px;
}
.runtime-drawer-section-title,
.runtime-field-label {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 8px;
}
.runtime-segmented {
display: flex;
gap: 8px;
}
.runtime-segmented-btn,
.runtime-footer-btn {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
}
.runtime-segmented-btn {
flex: 1;
padding: 11px 12px;
}
.runtime-segmented-btn.active {
border-color: rgba(72, 166, 255, 0.55);
background: rgba(72, 166, 255, 0.16);
}
.runtime-field-input,
.runtime-static-value {
width: 100%;
min-height: 44px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text);
padding: 11px 13px;
font-size: 14px;
font-family: inherit;
}
.runtime-field-input.is-draft,
.runtime-static-value.is-draft,
.runtime-segmented-btn.is-draft,
.runtime-chip-selectable.is-draft {
border-color: rgba(72, 166, 255, 0.45);
box-shadow: 0 0 0 1px rgba(72, 166, 255, 0.14);
}
.runtime-field-input.is-invalid,
.runtime-static-value.is-invalid {
border-color: rgba(240, 64, 64, 0.45);
box-shadow: 0 0 0 1px rgba(240, 64, 64, 0.12);
}
.runtime-static-value {
display: flex;
align-items: center;
}
.runtime-field-select {
appearance: none;
}
.runtime-static-value-path {
word-break: break-all;
}
.runtime-grid-two {
display: grid;
gap: 12px;
}
.runtime-search-grow {
flex: 1;
}
.runtime-field-row {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-end;
margin-bottom: 14px;
}
.runtime-field-row-top {
align-items: flex-start;
}
.runtime-field-support {
color: var(--text2);
font-size: 13px;
line-height: 1.5;
}
.runtime-inline-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.runtime-inline-btn,
.runtime-preset,
.runtime-chip {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-family: inherit;
cursor: pointer;
}
.runtime-inline-btn {
min-height: 40px;
padding: 0 12px;
font-size: 13px;
font-weight: 700;
}
.runtime-inline-btn-danger {
border-color: rgba(240, 64, 64, 0.45);
background: rgba(240, 64, 64, 0.14);
color: #ffb7b7;
}
.runtime-inline-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.runtime-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
}
.runtime-chip-selectable {
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
}
.runtime-chip-selectable.active {
border-color: rgba(72, 166, 255, 0.55);
background: rgba(72, 166, 255, 0.16);
}
.runtime-chip span {
color: var(--text3);
}
.runtime-preset-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.runtime-preset-row-first {
margin-top: 0;
margin-bottom: 14px;
}
.runtime-preset {
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
}
.runtime-preset.active {
border-color: rgba(72, 166, 255, 0.55);
background: rgba(72, 166, 255, 0.16);
}
.runtime-search-results {
display: grid;
gap: 10px;
margin-top: 14px;
}
.runtime-search-card {
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
padding: 12px 14px;
}
.runtime-search-title {
color: var(--text);
font-size: 14px;
font-weight: 700;
margin-bottom: 4px;
}
.runtime-verified-file-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.runtime-verified-file {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(9, 14, 21, 0.72);
border: 1px solid rgba(255, 255, 255, 0.05);
word-break: break-word;
}
.runtime-verified-file-meta {
color: var(--text3);
font-size: 12px;
}
.runtime-checkbox-row {
display: flex;
gap: 10px;
align-items: flex-start;
color: var(--text2);
font-size: 14px;
line-height: 1.5;
}
.runtime-error-banner {
border-radius: 12px;
border: 1px solid rgba(240, 64, 64, 0.18);
background: rgba(240, 64, 64, 0.08);
color: #ffb7b7;
padding: 12px 14px;
line-height: 1.5;
}
.runtime-warning-banner {
border-radius: 12px;
border: 1px solid rgba(243, 211, 139, 0.18);
background: rgba(243, 211, 139, 0.08);
color: #f3d38b;
padding: 12px 14px;
line-height: 1.5;
}
.runtime-apply-guidance {
border-radius: 12px;
border: 1px solid rgba(72, 166, 255, 0.2);
background: rgba(72, 166, 255, 0.08);
color: #9cd2ff;
padding: 12px 14px;
line-height: 1.5;
}
.runtime-apply-guidance.is-warning,
.runtime-apply-guidance-blocked {
border-color: rgba(243, 211, 139, 0.22);
background: rgba(243, 211, 139, 0.08);
color: #f3d38b;
}
.runtime-apply-guidance-failed {
border-color: rgba(240, 64, 64, 0.22);
background: rgba(240, 64, 64, 0.08);
color: #ffb7b7;
}
.runtime-apply-guidance-succeeded {
border-color: rgba(48, 209, 88, 0.22);
background: rgba(48, 209, 88, 0.08);
color: #baf0c8;
}
.runtime-field-state {
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
.runtime-field-state.is-draft {
border-color: rgba(72, 166, 255, 0.24);
background: rgba(72, 166, 255, 0.08);
}
.runtime-field-state.is-invalid {
border-color: rgba(240, 64, 64, 0.26);
background: rgba(240, 64, 64, 0.08);
}
.runtime-field-state-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.runtime-field-state-label {
font-size: 12px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.runtime-field-state-value,
.runtime-field-error-text {
font-size: 13px;
line-height: 1.5;
}
.runtime-field-state-value {
color: var(--text2);
}
.runtime-field-error-text {
margin-top: 6px;
color: #ffb7b7;
}
.runtime-footer-btn {
flex: 1;
padding: 12px 14px;
}
.runtime-footer-main-actions {
display: flex;
gap: 10px;
flex: 1;
}
.runtime-footer-btn-tertiary {
flex: 1.2;
}
.runtime-discard-confirmation {
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 14px 22px 20px;
background: rgba(20, 32, 45, 0.94);
}
.runtime-discard-copy {
color: var(--text2);
line-height: 1.5;
margin-bottom: 10px;
}
.runtime-footer-btn:active:not(:disabled),
.runtime-segmented-btn:active:not(:disabled) {
transform: scale(0.98);
}
.runtime-footer-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-footer-btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
}
.runtime-footer-btn-primary {
color: #fff;
border-color: rgba(72, 166, 255, 0.55);
background: linear-gradient(135deg, #2c8ce8, #49c0ff);
}
@media (max-width: 720px) {
.runtime-drawer {
width: 100%;
}
.runtime-overview-card,
.runtime-grid-two,
.runtime-field-row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.runtime-drawer-footer {
flex-direction: column;
}
.runtime-footer-main-actions {
width: 100%;
}
}
@@ -0,0 +1,962 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
getModelLibraryState,
pickRuntimeModelFiles,
pickRuntimeModelsDir,
queryRuntimeGpu,
scanRuntimeModels,
searchHuggingFaceModels,
} from "../api";
import {
chooseModel,
createRuntimeConfigPatch,
dedupeModels,
getLocalRuntimeStartReason,
pathName,
resolveDraftModelSelection,
toModelFile,
} from "../runtime-settings-form";
import {
createRuntimeDraftFieldState,
type ModelLibraryState,
type HuggingFaceModelResult,
type RuntimeConfig,
type RuntimeDraftFieldKey,
type RuntimeDraftFieldMeta,
type RuntimeDraftFieldState,
type RuntimeGpuInfo,
type RuntimeLifecycleResult,
type RuntimeModelFile,
type RuntimeSnapshot,
} from "../types";
import "./RuntimeSettingsDrawer.css";
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
const REMOTE_PRESET_TEMPLATES = [
{ id: "openai", label: "OpenAI", provider: "open_ai", baseUrl: "https://api.openai.com/v1" },
{ id: "openrouter", label: "OpenRouter", provider: "open_router", baseUrl: "https://openrouter.ai/api/v1" },
{ id: "anthropic", label: "Anthropic", provider: "anthropic", baseUrl: "https://api.anthropic.com/v1" },
] as const;
interface Props {
open: boolean;
snapshot: RuntimeSnapshot;
busy: boolean;
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
lastLifecycleResult?: RuntimeLifecycleResult;
lifecycleBusy?: boolean;
applyImpact?: "immediate" | "restart_required" | "save_before_apply";
onClose: () => void;
onSave: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
onStart: () => Promise<unknown>;
onStop: () => Promise<unknown>;
draftConfig?: RuntimeConfig;
savedBaseline?: RuntimeConfig;
draftDirty?: boolean;
draftFields?: RuntimeDraftFieldState;
onUpdateDraft?: (patch: Partial<RuntimeConfig>) => void;
onUpdateDraftFieldMeta?: (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => void;
onResetDraft?: () => void;
}
function valuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function maskSecret(value: string): string {
if (!value.trim()) return "Not configured";
return `${"*".repeat(Math.max(4, Math.min(12, value.trim().length)))} (${value.trim().length} chars)`;
}
function formatReferenceValue(field: keyof RuntimeConfig, value: RuntimeConfig[keyof RuntimeConfig]): string {
switch (field) {
case "connection_mode":
return value === "remote" ? "Remote API" : "Local API";
case "start_policy":
return value === "on_app_launch" ? "Start on app launch" : "Start manually";
case "server_url":
return String(value || "Not configured");
case "api_key":
return maskSecret(String(value || ""));
case "model_path":
return value ? pathName(String(value)) : "No model selected";
case "models_dir":
return String(value || "No folder selected");
case "addr":
return `http://${String(value || "127.0.0.1:8080")}`;
case "gpu_layers":
return `${String(value)} GPU layers`;
case "ctx_size":
return `${String(value)} context tokens`;
case "selected_model_id":
return String(value || "No managed model selected");
case "hugging_face_token":
return maskSecret(String(value || ""));
default:
return Array.isArray(value) ? `${value.length} configured` : String(value || "-");
}
}
export default function RuntimeSettingsDrawer({
open,
snapshot,
busy,
activeLifecycleCommand = snapshot.active_command,
lastLifecycleResult = snapshot.last_command_result,
lifecycleBusy = false,
applyImpact = "immediate",
onClose,
onSave,
onStart,
onStop,
draftConfig: controlledDraftConfig,
savedBaseline: controlledSavedBaseline,
draftDirty: controlledDraftDirty,
draftFields: controlledDraftFields,
onUpdateDraft,
onUpdateDraftFieldMeta,
onResetDraft,
}: Props) {
const draftConfig = controlledDraftConfig ?? snapshot.config;
const savedBaseline = controlledSavedBaseline ?? snapshot.config;
const draftFields = controlledDraftFields ?? createRuntimeDraftFieldState();
const draftDirty = controlledDraftDirty ?? !valuesEqual(draftConfig, savedBaseline);
const draftConfigRef = useRef(draftConfig);
const requestIdsRef = useRef<Record<string, number>>({});
const [scannedModels, setScannedModels] = useState<RuntimeModelFile[]>([]);
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
const [gpuError, setGpuError] = useState("");
const [localError, setLocalError] = useState("");
const [scanning, setScanning] = useState(false);
const [hfQuery, setHfQuery] = useState("");
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
const [hfLoading, setHfLoading] = useState(false);
const [libraryState, setLibraryState] = useState<ModelLibraryState>({ entries: [], managed_dir: "" });
const [confirmDiscard, setConfirmDiscard] = useState(false);
useEffect(() => {
draftConfigRef.current = draftConfig;
}, [draftConfig]);
const updateDraft = (patch: Partial<RuntimeConfig>) => {
onUpdateDraft?.(patch);
};
const updateDraftFieldMeta = (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => {
onUpdateDraftFieldMeta?.(field, patch);
};
const beginRequest = (key: string) => {
const nextId = (requestIdsRef.current[key] ?? 0) + 1;
requestIdsRef.current[key] = nextId;
return nextId;
};
const isLatestRequest = (key: string, requestId: number) => requestIdsRef.current[key] === requestId;
const extraModels = useMemo(
() => draftConfig.extra_model_paths.map(toModelFile),
[draftConfig.extra_model_paths],
);
const allModels = useMemo(() => {
const merged = dedupeModels([...scannedModels, ...extraModels]);
if (draftConfig.model_path && !merged.some((model) => model.path === draftConfig.model_path)) {
return [toModelFile(draftConfig.model_path), ...merged];
}
return merged;
}, [draftConfig.model_path, extraModels, scannedModels]);
const dirtyFieldCount = useMemo(() => {
const trackedFields: Array<keyof RuntimeConfig> = [
"connection_mode",
"server_url",
"api_key",
"models_dir",
"model_path",
"addr",
"ctx_size",
"gpu_layers",
"start_policy",
"hugging_face_token",
];
return trackedFields.filter((field) => !valuesEqual(draftConfig[field], savedBaseline[field])).length;
}, [draftConfig, savedBaseline]);
const hasInvalidDraft = Object.values(draftFields).some((meta) => meta?.invalid);
const hasPendingDraft = Object.values(draftFields).some((meta) => meta?.pending);
const hasBlockingDraftState = hasInvalidDraft || hasPendingDraft;
const hasPendingModeChange = draftConfig.connection_mode !== savedBaseline.connection_mode;
const restartRequiredWhileRunning =
draftDirty && draftConfig.connection_mode === "local" && snapshot.process_running && applyImpact === "restart_required";
const maxLayers = gpuInfo?.recommended_layers ?? 200;
const startReason = getLocalRuntimeStartReason({
mode: draftConfig.connection_mode,
binaryPath: snapshot.binary_path,
selectedModel: draftConfig.model_path || "",
});
const isFieldDirty = (field: keyof RuntimeConfig) => !valuesEqual(draftConfig[field], savedBaseline[field]);
const fieldMeta = (field: RuntimeDraftFieldKey) => draftFields[field] ?? { invalid: false, pending: false, message: null };
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
const applyGuidance = !draftDirty || draftConfig.connection_mode !== "local"
? null
: hasPendingModeChange || applyImpact === "save_before_apply"
? "Stop the local API or save first before applying this change."
: applyImpact === "restart_required"
? "Restart required to apply saved changes."
: "Applies immediately after save.";
const saveLabel = activeLifecycleCommand === "save" ? "Saving changes..." : "Save changes";
const tertiaryLabel = hasPendingModeChange
? "Save mode before applying"
: activeLifecycleCommand === "start"
? "Starting local API..."
: activeLifecycleCommand === "stop"
? "Stopping local API..."
: restartRequiredWhileRunning
? "Restart required to apply saved changes"
: snapshot.process_running
? "Stop local API"
: "Save and start local API";
const renderFieldState = (field: RuntimeDraftFieldKey) => {
const meta = fieldMeta(field);
const dirty = isFieldDirty(field);
if (!dirty && !meta.invalid && !meta.pending) {
return null;
}
return (
<div className={`runtime-field-state${dirty ? " is-draft" : ""}${meta.invalid ? " is-invalid" : ""}`}>
<div className="runtime-field-state-row">
{dirty && <span className="runtime-field-badge">Draft</span>}
{meta.invalid && <span className="runtime-field-badge runtime-field-badge-danger">Invalid</span>}
{meta.pending && <span className="runtime-field-badge runtime-field-badge-pending">Pending</span>}
{dirty && (
<>
<span className="runtime-field-state-label">Live now</span>
<span className="runtime-field-state-value">{formatReferenceValue(field, savedBaseline[field])}</span>
</>
)}
</div>
{meta.message && <div className="runtime-field-error-text">{meta.message}</div>}
</div>
);
};
const applyResolvedModelState = (models: RuntimeModelFile[]) => {
const currentDraft = draftConfigRef.current;
const resolved = resolveDraftModelSelection(currentDraft.model_path || "", models, currentDraft.extra_model_paths.map(toModelFile));
updateDraft({
model_path: resolved.nextModel || null,
selected_model_id:
resolved.invalid || resolved.nextModel === currentDraft.model_path
? currentDraft.selected_model_id
: null,
});
updateDraftFieldMeta("model_path", {
invalid: resolved.invalid,
pending: false,
message: resolved.message,
});
};
const loadModelLibrary = async () => {
const requestId = beginRequest("library");
try {
const state = await getModelLibraryState();
if (!isLatestRequest("library", requestId)) return;
setLibraryState(state);
} catch (error) {
if (!isLatestRequest("library", requestId)) return;
setLocalError(String(error));
}
};
const loadGpuInfo = async () => {
const requestId = beginRequest("gpu");
try {
const info = await queryRuntimeGpu();
if (!isLatestRequest("gpu", requestId)) return;
setGpuInfo(info);
setGpuError("");
if (info && draftConfigRef.current.gpu_layers === 0) {
updateDraft({ gpu_layers: info.recommended_layers });
}
} catch (error) {
if (!isLatestRequest("gpu", requestId)) return;
setGpuError(String(error));
}
};
const handleRescan = async (dir = draftConfigRef.current.models_dir || "") => {
if (!dir.trim()) return;
const requestId = beginRequest("model-selection");
setScanning(true);
setLocalError("");
updateDraftFieldMeta("models_dir", { pending: true, invalid: false, message: null });
updateDraftFieldMeta("model_path", { pending: true });
try {
const models = await scanRuntimeModels(dir.trim());
if (!isLatestRequest("model-selection", requestId)) return;
setScannedModels(models);
applyResolvedModelState(models);
updateDraftFieldMeta("models_dir", { pending: false, invalid: false, message: null });
} catch (error) {
if (!isLatestRequest("model-selection", requestId)) return;
setLocalError(String(error));
setScannedModels([]);
updateDraftFieldMeta("models_dir", { pending: false });
updateDraftFieldMeta("model_path", { pending: false });
} finally {
if (isLatestRequest("model-selection", requestId)) {
setScanning(false);
}
}
};
const handlePickDir = async () => {
const requestId = beginRequest("model-selection");
setLocalError("");
updateDraftFieldMeta("models_dir", { pending: true, invalid: false, message: null });
updateDraftFieldMeta("model_path", { pending: true });
try {
const result = await pickRuntimeModelsDir();
if (!isLatestRequest("model-selection", requestId) || !result) return;
updateDraft({ models_dir: result.dir });
setScannedModels(result.models);
applyResolvedModelState(result.models);
updateDraftFieldMeta("models_dir", { pending: false, invalid: false, message: null });
} catch (error) {
if (!isLatestRequest("model-selection", requestId)) return;
setLocalError(String(error));
updateDraftFieldMeta("models_dir", { pending: false });
updateDraftFieldMeta("model_path", { pending: false });
}
};
const handlePickFiles = async () => {
const requestId = beginRequest("model-selection");
setLocalError("");
updateDraftFieldMeta("model_path", { pending: true });
try {
const picked = await pickRuntimeModelFiles();
if (!isLatestRequest("model-selection", requestId) || picked.length === 0) {
updateDraftFieldMeta("model_path", { pending: false });
return;
}
const currentDraft = draftConfigRef.current;
const nextExtra = dedupeModels([...currentDraft.extra_model_paths.map(toModelFile), ...picked]);
const nextModel = currentDraft.model_path || nextExtra[0]?.path || "";
updateDraft({
extra_model_paths: nextExtra.map((model) => model.path),
model_path: nextModel || null,
selected_model_id: currentDraft.model_path ? currentDraft.selected_model_id : null,
});
updateDraftFieldMeta("model_path", { pending: false, invalid: false, message: null });
} catch (error) {
if (!isLatestRequest("model-selection", requestId)) return;
setLocalError(String(error));
updateDraftFieldMeta("model_path", { pending: false });
}
};
const handleHubSearch = async () => {
if (!hfQuery.trim()) {
setHfResults([]);
return;
}
const requestId = beginRequest("hf-search");
setLocalError("");
setHfLoading(true);
try {
const results = await searchHuggingFaceModels(hfQuery.trim(), draftConfig.hugging_face_token.trim());
if (!isLatestRequest("hf-search", requestId)) return;
setHfResults(results);
} catch (error) {
if (!isLatestRequest("hf-search", requestId)) return;
setLocalError(String(error));
setHfResults([]);
} finally {
if (isLatestRequest("hf-search", requestId)) {
setHfLoading(false);
}
}
};
const handleSave = async () => {
setLocalError("");
try {
await onSave(buildPatch());
setConfirmDiscard(false);
} catch (error) {
setLocalError(String(error));
}
};
const handleSaveAndStart = async () => {
setLocalError("");
if (snapshot.process_running) {
try {
await onStop();
} catch (error) {
setLocalError(String(error));
}
return;
}
try {
const next = await onSave(buildPatch());
if (next.config.connection_mode !== "local") return;
if (!next.config.model_path?.trim()) return;
if (next.last_error?.code === "restart_required") return;
await onStart();
} catch (error) {
setLocalError(String(error));
}
};
useEffect(() => {
if (!open) return;
setConfirmDiscard(false);
setLocalError("");
setHfResults([]);
void loadModelLibrary();
void loadGpuInfo();
if (draftConfig.connection_mode === "local" && draftConfig.models_dir?.trim()) {
void handleRescan(draftConfig.models_dir);
}
}, [open]);
if (!open) return null;
const buildPatch = (): RuntimeConfig => ({
...createRuntimeConfigPatch(
savedBaseline,
{
mode: draftConfig.connection_mode,
startPolicy: draftConfig.start_policy,
serverUrl: draftConfig.server_url,
apiKey: draftConfig.api_key,
modelsDir: draftConfig.models_dir || "",
selectedModel: draftConfig.model_path || "",
selectedModelId: draftConfig.selected_model_id,
addr: draftConfig.addr,
gpuLayers: draftConfig.gpu_layers,
ctxSize: draftConfig.ctx_size,
extraModels,
},
maxLayers,
),
hugging_face_token: draftConfig.hugging_face_token.trim(),
remote_presets: draftConfig.remote_presets?.length
? draftConfig.remote_presets
: REMOTE_PRESET_TEMPLATES.map((preset) => ({
id: preset.id,
label: preset.label,
provider: preset.provider,
base_url: preset.baseUrl,
default_model: "",
})),
});
return (
<div className="runtime-drawer-shell" role="dialog" aria-modal="true" aria-label="Draft settings">
<button
className="runtime-drawer-backdrop"
onClick={() => {
setConfirmDiscard(false);
onClose();
}}
aria-label="Close runtime settings"
/>
<aside className="runtime-drawer">
<div className="runtime-drawer-header runtime-drawer-header-sticky">
<div>
<div className="runtime-drawer-kicker">Draft settings</div>
<h2>Draft settings</h2>
<div className="runtime-drawer-summary-row">
<span className={`runtime-drawer-summary-badge${draftDirty ? " is-dirty" : ""}`}>
{draftDirty ? `${dirtyFieldCount} unsaved ${dirtyFieldCount === 1 ? "change" : "changes"}` : "No draft changes yet"}
</span>
<span className="runtime-live-chip">Live now</span>
<span className="runtime-live-chip-value">
{snapshot.status === "ready"
? "Local API ready"
: snapshot.config.connection_mode === "remote"
? "Remote API mode"
: "Local API setup"}
</span>
</div>
<p className="runtime-drawer-copy">
Edit runtime settings as a draft. Close keeps this draft in-session until you save or discard it.
</p>
</div>
<button
className="runtime-drawer-close"
onClick={() => {
setConfirmDiscard(false);
onClose();
}}
type="button"
aria-label="Close runtime settings"
>
×
</button>
</div>
<div className="runtime-drawer-body">
<section className="runtime-overview-card">
<div>
<div className="runtime-overview-label">Current status</div>
<div className="runtime-overview-value">
{snapshot.status === "ready"
? "Local API ready"
: snapshot.config.connection_mode === "remote"
? "Remote API mode"
: "Local API setup"}
</div>
</div>
<div>
<div className="runtime-overview-label">Endpoint</div>
<div className="runtime-overview-value runtime-overview-mono">
{snapshot.config.connection_mode === "remote"
? snapshot.config.server_url || "Not configured"
: `http://${snapshot.config.addr}`}
</div>
</div>
<div>
<div className="runtime-overview-label">Saved model</div>
<div className="runtime-overview-value">
{savedBaseline.model_path ? pathName(savedBaseline.model_path) : "No model saved"}
</div>
</div>
</section>
<section className="runtime-drawer-section">
<div className="runtime-drawer-section-title">Connection mode</div>
<div className="runtime-segmented" role="tablist" aria-label="Connection mode">
<button
className={`runtime-segmented-btn${draftConfig.connection_mode === "local" ? " active" : ""}${isFieldDirty("connection_mode") ? " is-draft" : ""}`}
onClick={() => updateDraft({ connection_mode: "local" })}
type="button"
>
Local API
</button>
<button
className={`runtime-segmented-btn${draftConfig.connection_mode === "remote" ? " active" : ""}${isFieldDirty("connection_mode") ? " is-draft" : ""}`}
onClick={() => updateDraft({ connection_mode: "remote" })}
type="button"
>
Remote API
</button>
</div>
{isFieldDirty("connection_mode") && (
<div className="runtime-field-state is-draft">
<div className="runtime-field-state-row">
<span className="runtime-field-badge">Draft</span>
<span className="runtime-field-state-label">Live now</span>
<span className="runtime-field-state-value">
{formatReferenceValue("connection_mode", savedBaseline.connection_mode)}
</span>
</div>
</div>
)}
</section>
{draftConfig.connection_mode === "remote" ? (
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-label">Provider presets</div>
<div className="runtime-preset-row runtime-preset-row-first">
{REMOTE_PRESET_TEMPLATES.map((preset) => (
<button
key={preset.id}
className={`runtime-preset${draftConfig.server_url.trim() === preset.baseUrl ? " active" : ""}`}
onClick={() => updateDraft({ server_url: preset.baseUrl })}
type="button"
>
{preset.label}
</button>
))}
</div>
<label className="runtime-field-label" htmlFor="runtime-remote-url">Remote server URL</label>
<input
id="runtime-remote-url"
className={`runtime-field-input${isFieldDirty("server_url") ? " is-draft" : ""}${fieldMeta("server_url").invalid ? " is-invalid" : ""}`}
value={draftConfig.server_url}
onChange={(event) => updateDraft({ server_url: event.target.value })}
placeholder="https://api.example.com"
type="text"
/>
{renderFieldState("server_url")}
<label className="runtime-field-label" htmlFor="runtime-api-key">API key</label>
<input
id="runtime-api-key"
className={`runtime-field-input${isFieldDirty("api_key") ? " is-draft" : ""}${fieldMeta("api_key").invalid ? " is-invalid" : ""}`}
value={draftConfig.api_key}
onChange={(event) => updateDraft({ api_key: event.target.value })}
placeholder="sk-..."
type="password"
/>
{renderFieldState("api_key")}
</section>
) : (
<>
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
<div>
<div className="runtime-field-label">Managed endpoint</div>
<div className="runtime-static-value runtime-static-value-mono">http://{draftConfig.addr.trim() || snapshot.config.addr}</div>
</div>
<div>
<div className="runtime-field-label">Binary discovery</div>
<div className="runtime-static-value runtime-static-value-path">{snapshot.binary_path || "Binary not found yet"}</div>
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row runtime-field-row-top">
<div>
<div className="runtime-field-label">Managed library</div>
<div className="runtime-field-support">
App-managed downloads live outside your scanned folders so they survive app updates.
</div>
</div>
</div>
<div className="runtime-static-value runtime-static-value-path">
{libraryState.managed_dir || "Loading managed storage path..."}
</div>
{libraryState.entries.length > 0 ? (
<div className="runtime-chip-list">
{libraryState.entries.map((entry) => (
<button
key={entry.id}
className={`runtime-chip runtime-chip-selectable${draftConfig.selected_model_id === entry.id ? " active" : ""}${isFieldDirty("model_path") ? " is-draft" : ""}`}
onClick={() => updateDraft({ model_path: entry.path, selected_model_id: entry.id })}
type="button"
title={entry.path}
>
{entry.label}
</button>
))}
</div>
) : (
<div className="runtime-field-support">No managed downloads yet.</div>
)}
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row">
<div>
<div className="runtime-field-label">Models folder</div>
<div className={`runtime-static-value runtime-static-value-path${isFieldDirty("models_dir") ? " is-draft" : ""}${fieldMeta("models_dir").invalid ? " is-invalid" : ""}`}>
{draftConfig.models_dir || "Choose a folder or add individual files"}
</div>
</div>
<div className="runtime-inline-actions">
<button className="runtime-inline-btn" onClick={handlePickDir} type="button">Browse</button>
<button className="runtime-inline-btn" onClick={() => { void handleRescan(); }} type="button" disabled={!draftConfig.models_dir?.trim() || scanning}>
{scanning ? "Scanning..." : "Rescan"}
</button>
<button className="runtime-inline-btn" onClick={handlePickFiles} type="button">Add files</button>
</div>
</div>
{renderFieldState("models_dir")}
<label className="runtime-field-label" htmlFor="runtime-model-select">Selected model</label>
<select
id="runtime-model-select"
className={`runtime-field-input runtime-field-select${isFieldDirty("model_path") ? " is-draft" : ""}${fieldMeta("model_path").invalid ? " is-invalid" : ""}`}
value={draftConfig.model_path || ""}
onChange={(event) =>
updateDraft({
model_path: event.target.value || null,
selected_model_id: null,
})
}
>
<option value="">Choose a model</option>
{allModels.map((model) => (
<option key={model.path} value={model.path}>{model.name}</option>
))}
</select>
{renderFieldState("model_path")}
{extraModels.length > 0 && (
<div className="runtime-chip-list">
{extraModels.map((model) => (
<button
key={model.path}
className="runtime-chip"
onClick={() => {
const next = extraModels.filter((entry) => entry.path !== model.path);
updateDraft({
extra_model_paths: next.map((entry) => entry.path),
model_path:
draftConfig.model_path === model.path
? chooseModel("", scannedModels, next)
: draftConfig.model_path,
selected_model_id: draftConfig.model_path === model.path ? null : draftConfig.selected_model_id,
});
}}
type="button"
title={`Remove ${model.name}`}
>
{model.name}
<span>×</span>
</button>
))}
</div>
)}
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row runtime-field-row-top">
<div>
<div className="runtime-field-label">Discover from Hugging Face</div>
<div className="runtime-field-support">
Search public GGUF repos, preview verified files, and keep your local library as the primary runtime source.
</div>
</div>
</div>
<label className="runtime-field-label" htmlFor="runtime-hf-token">Optional Hugging Face token</label>
<input
id="runtime-hf-token"
className={`runtime-field-input${isFieldDirty("hugging_face_token") ? " is-draft" : ""}`}
value={draftConfig.hugging_face_token}
onChange={(event) => updateDraft({ hugging_face_token: event.target.value })}
placeholder="hf_..."
type="password"
/>
<div className="runtime-field-row runtime-field-row-top">
<div className="runtime-search-grow">
<label className="runtime-field-label" htmlFor="runtime-hf-search">Search public GGUF repos</label>
<input
id="runtime-hf-search"
className="runtime-field-input"
value={hfQuery}
onChange={(event) => setHfQuery(event.target.value)}
onInput={(event) => setHfQuery((event.target as HTMLInputElement).value)}
placeholder="phi-4, mistral, qwen..."
type="text"
/>
</div>
<button className="runtime-inline-btn" onClick={() => { void handleHubSearch(); }} type="button" disabled={hfLoading}>
{hfLoading ? "Searching..." : "Search Hub"}
</button>
</div>
{hfResults.length > 0 ? (
<div className="runtime-search-results" aria-label="Hugging Face search results">
{hfResults.map((result) => (
<article key={result.repo_id} className="runtime-search-card">
<div className="runtime-search-title">{result.repo_id}</div>
<div className="runtime-field-support">{result.author || "Unknown author"}</div>
<div className="runtime-verified-file-list">
{result.verified_files.map((file) => (
<div key={`${result.repo_id}:${file.path}`} className="runtime-verified-file">
<span>{file.path}</span>
<span className="runtime-verified-file-meta">
{file.quantization || "gguf"}
{file.size ? ` · ${Math.round(file.size / (1024 * 1024))} MiB` : ""}
</span>
</div>
))}
</div>
</article>
))}
</div>
) : (
<div className="runtime-field-support">
{hfLoading ? "Looking up verified GGUF files..." : "Search for a model family to preview compatible local installs."}
</div>
)}
</section>
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
<div>
<label className="runtime-field-label" htmlFor="runtime-addr">Listen address</label>
<input
id="runtime-addr"
className={`runtime-field-input${isFieldDirty("addr") ? " is-draft" : ""}`}
value={draftConfig.addr}
onChange={(event) => updateDraft({ addr: event.target.value })}
placeholder="127.0.0.1:8080"
type="text"
/>
</div>
<div>
<label className="runtime-field-label" htmlFor="runtime-context">Context size</label>
<input
id="runtime-context"
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
min={512}
step={512}
value={draftConfig.ctx_size}
onChange={(event) => updateDraft({ ctx_size: Math.max(512, parseInt(event.target.value, 10) || 4096) })}
type="number"
/>
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row runtime-field-row-top">
<div>
<div className="runtime-field-label">GPU layers</div>
<div className="runtime-field-support">
{gpuInfo ? `${gpuInfo.name} · recommended ${gpuInfo.recommended_layers}` : gpuError || "GPU detection unavailable"}
</div>
</div>
{gpuInfo && (
<button className="runtime-inline-btn" onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })} type="button">
Use recommended
</button>
)}
</div>
<input
className={`runtime-field-input${isFieldDirty("gpu_layers") ? " is-draft" : ""}`}
min={0}
max={maxLayers}
value={draftConfig.gpu_layers}
onChange={(event) => updateDraft({ gpu_layers: Math.max(0, Math.min(maxLayers, parseInt(event.target.value, 10) || 0)) })}
type="number"
/>
<div className="runtime-preset-row">
{CTX_PRESETS.map((preset) => (
<button
key={preset}
className={`runtime-preset${draftConfig.ctx_size === preset ? " active" : ""}`}
onClick={() => updateDraft({ ctx_size: preset })}
type="button"
>
{preset >= 1024 ? `${preset / 1024}k` : preset}
</button>
))}
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<label className="runtime-checkbox-row">
<input
checked={draftConfig.start_policy === "on_app_launch"}
onChange={(event) => updateDraft({ start_policy: event.target.checked ? "on_app_launch" : "manual" })}
type="checkbox"
/>
<span>Start the local API automatically when the desktop app opens</span>
</label>
</section>
</>
)}
{(localError || snapshot.last_error) && (
<section className="runtime-drawer-section">
<div className="runtime-error-banner">
{localError || snapshot.last_error?.message || "Couldn't save your draft. Review the highlighted fields and try again — your unsaved changes are still here."}
</div>
</section>
)}
{showLifecycleResult && (
<section className="runtime-drawer-section">
<div
className={`runtime-apply-guidance runtime-apply-guidance-${lastLifecycleResult.outcome}`}
role="status"
aria-live="polite"
>
{lastLifecycleResult.message}
</div>
</section>
)}
{applyGuidance && (
<section className="runtime-drawer-section">
<div
className={`runtime-apply-guidance${applyImpact !== "immediate" ? " is-warning" : ""}`}
role="status"
>
{applyGuidance}
</div>
</section>
)}
{!snapshot.process_running && draftConfig.connection_mode === "local" && startReason && (
<section className="runtime-drawer-section">
<div className="runtime-warning-banner">{startReason}</div>
</section>
)}
</div>
<div className="runtime-drawer-footer runtime-drawer-footer-sticky">
<div className="runtime-footer-main-actions">
<button
className="runtime-footer-btn runtime-footer-btn-secondary"
onClick={() => setConfirmDiscard(true)}
type="button"
disabled={busy || lifecycleBusy || !draftDirty}
>
Discard changes
</button>
<button
className="runtime-footer-btn runtime-footer-btn-primary"
onClick={() => { void handleSave(); }}
type="button"
disabled={busy || lifecycleBusy || !draftDirty || hasBlockingDraftState}
>
{saveLabel}
</button>
</div>
{draftConfig.connection_mode === "local" && (
<button
className="runtime-footer-btn runtime-footer-btn-tertiary"
onClick={() => { void handleSaveAndStart(); }}
type="button"
disabled={
busy ||
lifecycleBusy ||
hasBlockingDraftState ||
hasPendingModeChange ||
restartRequiredWhileRunning ||
(!snapshot.process_running && Boolean(startReason))
}
>
{tertiaryLabel}
</button>
)}
</div>
{confirmDiscard && (
<div className="runtime-discard-confirmation">
<div className="runtime-discard-copy">Revert to saved: Replace all unsaved edits with the last saved values.</div>
<div className="runtime-inline-actions">
<button className="runtime-inline-btn" onClick={() => setConfirmDiscard(false)} type="button">
Keep editing
</button>
<button
className="runtime-inline-btn runtime-inline-btn-danger"
onClick={() => {
onResetDraft?.();
setConfirmDiscard(false);
}}
type="button"
>
Revert to saved
</button>
</div>
</div>
)}
</aside>
</div>
);
}
@@ -0,0 +1,188 @@
.runtime-card {
margin: 10px 12px 8px;
padding: 15px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
radial-gradient(circle at top right, rgba(72, 166, 255, 0.18), transparent 40%),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01));
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
}
.runtime-card-success {
border-color: rgba(48, 209, 88, 0.24);
}
.runtime-card-warning {
border-color: rgba(255, 214, 10, 0.24);
}
.runtime-card-danger {
border-color: rgba(240, 64, 64, 0.24);
}
.runtime-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.runtime-card-kicker {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 4px;
}
.runtime-card-title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.runtime-card-mode {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 8px;
border-radius: 999px;
border: 1px solid var(--border2);
color: var(--text2);
background: rgba(255, 255, 255, 0.04);
}
.runtime-card-mode-local {
color: #71c5ff;
border-color: rgba(113, 197, 255, 0.28);
}
.runtime-card-mode-remote {
color: #92e5c7;
border-color: rgba(146, 229, 199, 0.24);
}
.runtime-card-copy {
margin-top: 10px;
color: var(--text2);
font-size: 12px;
line-height: 1.5;
}
.runtime-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.runtime-card-meta span,
.runtime-card-note {
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text2);
font-size: 11px;
line-height: 1.3;
padding: 7px 10px;
}
.runtime-card-note {
display: block;
margin-top: 10px;
border-radius: 12px;
color: #f3d38b;
border-color: rgba(243, 211, 139, 0.16);
background: rgba(243, 211, 139, 0.08);
}
.runtime-card-error {
margin-top: 10px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(240, 64, 64, 0.2);
background: rgba(240, 64, 64, 0.08);
color: #ffb4b4;
font-size: 11px;
line-height: 1.5;
}
.runtime-card-result {
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: var(--text);
font-size: 12px;
line-height: 1.5;
}
.runtime-card-result-success {
border-color: rgba(48, 209, 88, 0.22);
background: rgba(48, 209, 88, 0.1);
color: #baf0c8;
}
.runtime-card-result-warning {
border-color: rgba(243, 211, 139, 0.24);
background: rgba(243, 211, 139, 0.1);
color: #f3d38b;
}
.runtime-card-result-danger {
border-color: rgba(240, 64, 64, 0.22);
background: rgba(240, 64, 64, 0.1);
color: #ffb7b7;
}
.runtime-card-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.runtime-card-btn {
flex: 1;
border-radius: 10px;
border: 1px solid var(--border2);
padding: 9px 12px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.runtime-card-btn:active:not(:disabled) {
transform: scale(0.98);
}
.runtime-card-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-card-btn-secondary {
color: var(--text);
background: rgba(255, 255, 255, 0.04);
}
.runtime-card-btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
}
.runtime-card-btn-primary {
color: #fff;
border-color: rgba(72, 166, 255, 0.6);
background: linear-gradient(135deg, #2b88e6, #46c2ff);
}
.runtime-card-btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #3193f5, #55cbff);
}
@@ -0,0 +1,114 @@
import { getRuntimeStartGuard, getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime-utils";
import type { RuntimeLifecycleResult, RuntimeSnapshot } from "../types";
import "./RuntimeStatusCard.css";
function summarizePath(path: string | null): string {
if (!path) return "No model selected";
const segments = path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || path;
}
interface Props {
snapshot: RuntimeSnapshot;
loading: boolean;
busy: boolean;
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
lastLifecycleResult?: RuntimeLifecycleResult;
lifecycleBusy?: boolean;
onOpenSettings: () => void;
onStart: () => void;
onStop: () => void;
}
function getLifecycleActionLabel(command: RuntimeSnapshot["active_command"]): string | null {
switch (command) {
case "start":
return "Starting local API...";
case "stop":
return "Stopping local API...";
case "save":
return "Saving changes...";
case "save_and_start":
return "Saving and starting local API...";
default:
return null;
}
}
export default function RuntimeStatusCard({
snapshot,
loading,
busy,
activeLifecycleCommand = snapshot.active_command,
lastLifecycleResult = snapshot.last_command_result,
lifecycleBusy = false,
onOpenSettings,
onStart,
onStop,
}: Props) {
const tone = getRuntimeStatusTone(snapshot);
const isLocal = snapshot.config.connection_mode === "local";
const startGuard = getRuntimeStartGuard(snapshot);
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand);
const primaryLabel = lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
const primaryDisabled = busy || lifecycleBusy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
const lifecycleResultClass =
lastLifecycleResult.outcome === "failed"
? "runtime-card-result-danger"
: lastLifecycleResult.outcome === "blocked"
? "runtime-card-result-warning"
: "runtime-card-result-success";
return (
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
<div className="runtime-card-header">
<div>
<div className="runtime-card-kicker">Runtime Manager</div>
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
</div>
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
</span>
</div>
<p className="runtime-card-copy">
{snapshot.config.connection_mode === "local"
? `Managed endpoint ${snapshot.resolved_url}`
: snapshot.config.server_url || "Configure a remote API endpoint"}
</p>
<div className="runtime-card-meta" aria-label="Runtime details">
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
</div>
{needsSetup && <div className="runtime-card-note">{startGuard.reason}</div>}
{showLifecycleResult && (
<div className={`runtime-card-result ${lifecycleResultClass}`} role="status" aria-live="polite">
{lastLifecycleResult.message}
</div>
)}
{snapshot.last_error && (
<div className="runtime-card-error" role="status">
{snapshot.last_error.message}
</div>
)}
<div className="runtime-card-actions">
<button className="runtime-card-btn runtime-card-btn-secondary" onClick={onOpenSettings} type="button" disabled={lifecycleBusy}>
Manage
</button>
{snapshot.config.connection_mode === "local" && (
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
{primaryLabel}
</button>
)}
</div>
</section>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,267 @@
.runtime-workspace {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--surface-elevated, #fff);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.runtime-workspace-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--text-secondary, #666);
font-size: 14px;
}
.runtime-workspace-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.runtime-workspace-header-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.runtime-workspace-kicker {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary, #666);
}
.runtime-workspace-mode {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-muted, #f5f5f5);
color: var(--text-secondary, #666);
}
.runtime-workspace-mode-local {
background: #e8f5e9;
color: #2e7d32;
}
.runtime-workspace-mode-remote {
background: #e3f2fd;
color: #1565c0;
}
.runtime-workspace-status {
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #111);
}
.runtime-workspace-health {
padding: 12px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.runtime-workspace-health-neutral {
background: var(--bg-muted, #f5f5f5);
}
.runtime-workspace-health-success {
background: #e8f5e9;
}
.runtime-workspace-health-warning {
background: #fff3e0;
}
.runtime-workspace-health-danger {
background: #ffebee;
}
.runtime-workspace-health-headline {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #111);
}
.runtime-workspace-health-detail {
font-size: 13px;
color: var(--text-secondary, #666);
}
.runtime-workspace-details {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--bg-muted, #f5f5f5);
border-radius: 6px;
}
.runtime-workspace-detail-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.runtime-workspace-detail-label {
color: var(--text-secondary, #666);
}
.runtime-workspace-detail-value {
font-weight: 500;
color: var(--text-primary, #111);
font-family: monospace;
font-size: 12px;
}
.runtime-workspace-result {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.runtime-workspace-result-success {
background: #e8f5e9;
color: #2e7d32;
}
.runtime-workspace-result-warning {
background: #fff3e0;
color: #e65100;
}
.runtime-workspace-result-danger {
background: #ffebee;
color: #c62828;
}
.runtime-workspace-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.runtime-workspace-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background-color 0.15s ease;
}
.runtime-workspace-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.runtime-workspace-btn-primary {
background: var(--primary, #0066cc);
color: white;
}
.runtime-workspace-btn-primary:hover:not(:disabled) {
background: var(--primary-dark, #0052a3);
}
.runtime-workspace-btn-secondary {
background: var(--bg-muted, #f5f5f5);
color: var(--text-primary, #111);
border: 1px solid var(--border-color, #e0e0e0);
}
.runtime-workspace-btn-secondary:hover:not(:disabled) {
background: var(--bg-hover, #eee);
}
.runtime-workspace-draft-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--surface-elevated, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
}
.runtime-workspace-draft-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.runtime-workspace-draft-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #111);
}
.runtime-workspace-draft-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: #fff3e0;
color: #e65100;
}
.runtime-workspace-apply-guidance {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
background: #e8f5e9;
color: #2e7d32;
}
.runtime-workspace-apply-guidance.is-warning {
background: #fff3e0;
color: #e65100;
}
.runtime-workspace-draft-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.runtime-workspace-draft-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #666);
}
.runtime-workspace-draft-input {
padding: 8px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 14px;
background: white;
}
.runtime-workspace-draft-input:focus {
outline: none;
border-color: var(--primary, #0066cc);
}
.runtime-workspace-draft-input.is-invalid {
border-color: #c62828;
}
.runtime-workspace-draft-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
@@ -0,0 +1,311 @@
import { useRuntimeManager } from "../useRuntimeManager";
import { getRuntimeHealthSummary, getRuntimeWorkspaceSummary } from "../runtime-utils";
import type { RuntimeConfig, RuntimeDraftFieldKey, RuntimeDraftFieldMeta, RuntimeDraftFieldState, RuntimeSnapshot, RuntimeWorkspaceHealth, RuntimeWorkspaceSummary } from "../types";
import "./RuntimeWorkspace.css";
interface ExternalRuntimeProps {
snapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
draftDirty: boolean;
draftOpen?: boolean;
draftFields: RuntimeDraftFieldState;
loading?: boolean;
busy?: boolean;
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
lastLifecycleResult?: RuntimeSnapshot["last_command_result"];
lifecycleBusy?: boolean;
applyImpact?: "immediate" | "restart_required" | "save_before_apply";
openDraft?: () => void;
closeDraft?: () => void;
resetDraft?: () => void;
updateDraft?: (patch: Partial<RuntimeConfig>) => void;
updateDraftField?: <K extends RuntimeDraftFieldKey>(field: K, value: RuntimeConfig[K]) => void;
updateDraftFieldMeta?: (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => void;
saveConfig?: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
startLocal?: () => Promise<unknown>;
stopLocal?: () => Promise<unknown>;
}
interface Props {
runtime?: ExternalRuntimeProps;
}
function summarizePath(path: string | null): string {
if (!path) return "No model selected";
const segments = path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || path;
}
function getLifecycleActionLabel(command: string): string | null {
switch (command) {
case "start":
return "Starting local API...";
case "stop":
return "Stopping local API...";
case "save":
return "Saving changes...";
case "save_and_start":
return "Saving and starting local API...";
default:
return null;
}
}
export default function RuntimeWorkspace({ runtime: externalRuntime }: Props) {
const internalManager = useRuntimeManager();
// Use external runtime props if provided, otherwise use internal manager
const useExternal = !!externalRuntime;
const runtime = useExternal ? externalRuntime : internalManager;
// Compute workspace summary and health from snapshot when using external props
const workspaceSummary: RuntimeWorkspaceSummary = useExternal
? getRuntimeWorkspaceSummary(runtime.snapshot)
: internalManager.workspaceSummary;
const workspaceHealth: RuntimeWorkspaceHealth = useExternal
? getRuntimeHealthSummary(runtime.snapshot)
: internalManager.workspaceHealth;
const {
draftDirty,
draftConfig,
savedBaseline,
draftFields,
applyImpact,
busy,
activeLifecycleCommand,
lastLifecycleResult,
lifecycleBusy,
loading,
openDraft,
closeDraft,
resetDraft,
updateDraft,
updateDraftField,
updateDraftFieldMeta,
saveConfig,
startLocal,
stopLocal,
snapshot,
} = runtime;
const isLocal = snapshot.config.connection_mode === "local";
const lifecycleResult = lastLifecycleResult ?? { command: "idle" as const, outcome: "succeeded" as const, message: "" };
const showLifecycleResult = lifecycleResult.command !== "idle" && !lifecycleBusy;
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand ?? "");
const hasInvalidDraft = Object.values(draftFields).some((meta) => meta?.invalid);
const hasPendingDraft = Object.values(draftFields).some((meta) => meta?.pending);
const hasBlockingDraftState = hasInvalidDraft || hasPendingDraft;
const hasPendingModeChange = draftConfig.connection_mode !== savedBaseline.connection_mode;
const restartRequiredWhileRunning =
draftDirty && draftConfig.connection_mode === "local" && snapshot.process_running && applyImpact === "restart_required";
const applyGuidance = !draftDirty || draftConfig.connection_mode !== "local"
? null
: hasPendingModeChange || applyImpact === "save_before_apply"
? "Stop the local API or save first before applying this change."
: applyImpact === "restart_required"
? "Restart required to apply saved changes."
: "Applies immediately after save.";
const lifecycleResultClass =
lifecycleResult.outcome === "failed"
? "runtime-workspace-result-danger"
: lifecycleResult.outcome === "blocked"
? "runtime-workspace-result-warning"
: "runtime-workspace-result-success";
const healthToneClass = `runtime-workspace-health-${workspaceHealth.tone}`;
const saveLabel = activeLifecycleCommand === "save" ? "Saving changes..." : "Save changes";
const tertiaryLabel = hasPendingModeChange
? "Save mode before applying"
: activeLifecycleCommand === "start"
? "Starting local API..."
: activeLifecycleCommand === "stop"
? "Stopping local API..."
: restartRequiredWhileRunning
? "Restart required to apply saved changes"
: snapshot.process_running
? "Stop local API"
: "Save and start local API";
if (loading) {
return (
<section className="runtime-workspace" aria-label="Runtime workspace">
<div className="runtime-workspace-loading">Loading runtime...</div>
</section>
);
}
return (
<section className="runtime-workspace" aria-label="Runtime workspace">
{/* Header / Summary Section */}
<div className="runtime-workspace-header">
<div className="runtime-workspace-header-top">
<div className="runtime-workspace-kicker">Runtime workspace</div>
<span className={`runtime-workspace-mode runtime-workspace-mode-${workspaceSummary.modeLabel}`}>
{workspaceSummary.modeLabel === "local" ? "Local" : "Remote"}
</span>
</div>
<div className="runtime-workspace-status">{workspaceSummary.statusLabel}</div>
</div>
{/* Health Section */}
<div className="runtime-workspace-health-section">
<div className="runtime-workspace-section-label">Runtime health</div>
<div className={`runtime-workspace-health ${healthToneClass}`}>
<div className="runtime-workspace-health-headline">{workspaceHealth.headline}</div>
{workspaceHealth.detail && <div className="runtime-workspace-health-detail">{workspaceHealth.detail}</div>}
</div>
</div>
{/* Connection Details */}
<div className="runtime-workspace-details">
<div className="runtime-workspace-detail-row">
<span className="runtime-workspace-detail-label">Endpoint</span>
<span className="runtime-workspace-detail-value">{workspaceSummary.endpointLabel || "Not configured"}</span>
</div>
<div className="runtime-workspace-detail-row">
<span className="runtime-workspace-detail-label">Model</span>
<span className="runtime-workspace-detail-value">{workspaceSummary.modelLabel || "No model selected"}</span>
</div>
{isLocal && (
<div className="runtime-workspace-detail-row">
<span className="runtime-workspace-detail-label">Start policy</span>
<span className="runtime-workspace-detail-value">
{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual"}
</span>
</div>
)}
</div>
{/* Lifecycle Result Banner */}
{showLifecycleResult && (
<div className={`runtime-workspace-result ${lifecycleResultClass}`} role="status" aria-live="polite">
{lifecycleResult.message}
</div>
)}
{/* Actions */}
<div className="runtime-workspace-actions">
<button className="runtime-workspace-btn runtime-workspace-btn-secondary" onClick={openDraft ?? (() => {})} type="button" disabled={lifecycleBusy}>
{draftDirty ? "Edit draft" : "Manage"}
</button>
{isLocal && (
<>
<button
className="runtime-workspace-btn runtime-workspace-btn-primary"
onClick={snapshot.process_running ? (stopLocal ?? (() => {})) : (startLocal ?? (() => {}))}
type="button"
disabled={busy || lifecycleBusy || (snapshot.process_running && snapshot.status === "stopping")}
>
{lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
</button>
</>
)}
</div>
{/* Draft Settings (inline when open) */}
{draftDirty && (
<div className="runtime-workspace-draft-panel">
<div className="runtime-workspace-draft-header">
<span className="runtime-workspace-draft-title">Draft settings</span>
<span className="runtime-workspace-draft-badge">Unsaved changes</span>
</div>
{/* Apply Guidance */}
{applyGuidance && (
<div className={`runtime-workspace-apply-guidance${applyImpact !== "immediate" ? " is-warning" : ""}`}>
{applyGuidance}
</div>
)}
{/* Local mode draft fields */}
{draftConfig.connection_mode === "local" && updateDraft && (
<>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">Models folder</label>
<input
className="runtime-workspace-draft-input"
value={draftConfig.models_dir || ""}
onChange={(e) => updateDraft({ models_dir: e.target.value })}
placeholder="D:/models"
/>
</div>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">Selected model</label>
<input
className="runtime-workspace-draft-input"
value={draftConfig.model_path || ""}
onChange={(e) => updateDraft({ model_path: e.target.value || null })}
placeholder="D:/models/model.gguf"
/>
</div>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">GPU layers</label>
<input
className="runtime-workspace-draft-input"
type="number"
min={0}
value={draftConfig.gpu_layers}
onChange={(e) => updateDraft({ gpu_layers: parseInt(e.target.value, 10) || 0 })}
/>
</div>
</>
)}
{/* Remote mode draft fields */}
{draftConfig.connection_mode === "remote" && updateDraft && (
<>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">Server URL</label>
<input
className="runtime-workspace-draft-input"
value={draftConfig.server_url}
onChange={(e) => updateDraft({ server_url: e.target.value })}
placeholder="https://api.example.com"
/>
</div>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">API key</label>
<input
className="runtime-workspace-draft-input"
type="password"
value={draftConfig.api_key}
onChange={(e) => updateDraft({ api_key: e.target.value })}
placeholder="sk-..."
/>
</div>
</>
)}
{/* Draft Actions */}
{updateDraft && resetDraft && (
<div className="runtime-workspace-draft-actions">
<button
className="runtime-workspace-btn runtime-workspace-btn-secondary"
onClick={resetDraft}
type="button"
disabled={busy || lifecycleBusy}
>
Discard
</button>
<button
className="runtime-workspace-btn runtime-workspace-btn-primary"
onClick={() => { void saveConfig?.(draftConfig); }}
type="button"
disabled={busy || lifecycleBusy || hasBlockingDraftState}
>
{saveLabel}
</button>
</div>
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,309 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import {
chooseModel,
createRuntimeConfigPatch,
dedupeModels,
getLocalRuntimeStartReason,
pathName,
resolveDraftModelSelection,
toModelFile,
type RuntimeSettingsFormState,
} from "./runtime-settings-form";
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
import {
createRuntimeDraftState,
RUNTIME_DRAFT_FIELD_KEYS,
} from "./types";
import type {
RuntimeDraftFieldKey,
RuntimeDraftFieldMeta,
RuntimeDraftFieldState,
RuntimeDraftState,
RuntimeModelFile,
} from "./types";
function createFormState(overrides: Partial<RuntimeSettingsFormState> = {}): RuntimeSettingsFormState {
return {
mode: "local",
startPolicy: "manual",
serverUrl: "https://api.example.com/",
apiKey: " sk-demo ",
modelsDir: "D:/models",
selectedModel: "D:/models/demo.gguf",
selectedModelId: null,
addr: "127.0.0.1:8080",
gpuLayers: 48,
ctxSize: 8192,
extraModels: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
...overrides,
};
}
describe("runtime settings form helpers", () => {
it("defines runtime draft ownership contracts separately from the live snapshot", () => {
expect(RUNTIME_DRAFT_FIELD_KEYS).toEqual([
"models_dir",
"model_path",
"selected_model_id",
"server_url",
"api_key",
]);
const snapshot = createDefaultRuntimeSnapshot();
const draftState = createRuntimeDraftState(snapshot);
expect(draftState).toMatchObject({
liveSnapshot: snapshot,
savedBaseline: snapshot.config,
draftConfig: snapshot.config,
isDirty: false,
isOpen: false,
fields: {
models_dir: { invalid: false, pending: false, message: null },
model_path: { invalid: false, pending: false, message: null },
selected_model_id: { invalid: false, pending: false, message: null },
server_url: { invalid: false, pending: false, message: null },
api_key: { invalid: false, pending: false, message: null },
},
});
expectTypeOf<RuntimeDraftFieldKey>().toEqualTypeOf<
"models_dir" | "model_path" | "selected_model_id" | "server_url" | "api_key"
>();
expectTypeOf<RuntimeDraftFieldMeta>().toMatchObjectType<{
invalid: boolean;
pending: boolean;
message: string | null;
}>();
expectTypeOf<RuntimeDraftFieldState>().toMatchTypeOf<
Partial<Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta>>
>();
expectTypeOf<RuntimeDraftState>().toMatchObjectType<{
liveSnapshot: ReturnType<typeof createDefaultRuntimeSnapshot>;
savedBaseline: ReturnType<typeof createDefaultRuntimeSnapshot>["config"];
draftConfig: ReturnType<typeof createDefaultRuntimeSnapshot>["config"];
isDirty: boolean;
isOpen: boolean;
fields: RuntimeDraftFieldState;
scannedModels: RuntimeModelFile[];
isScanning: boolean;
}>();
});
it("keeps the current model when it still exists", () => {
const current = "D:/models/demo.gguf";
const scanned = [{ name: "demo.gguf", path: current }];
const extra = [{ name: "other.gguf", path: "D:/models/other.gguf" }];
expect(chooseModel(current, scanned, extra)).toBe(current);
});
it("falls back to the first available model when the current one disappears", () => {
expect(
chooseModel(
"D:/models/missing.gguf",
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
[],
),
).toBe("D:/models/demo.gguf");
});
it("keeps the drafted model during rescans when it is still valid", () => {
expect(
resolveDraftModelSelection(
"D:/models/demo.gguf",
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
[],
),
).toEqual({
nextModel: "D:/models/demo.gguf",
invalid: false,
message: null,
});
});
it("keeps an invalid drafted model visible instead of silently falling back", () => {
expect(
resolveDraftModelSelection(
"D:/models/missing.gguf",
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
[],
),
).toEqual({
nextModel: "D:/models/missing.gguf",
invalid: true,
message: "This draft model is no longer available in the current library or folder scan.",
});
});
it("chooses the first discovered model when there is no existing draft selection", () => {
expect(
resolveDraftModelSelection(
"",
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
[],
),
).toEqual({
nextModel: "D:/models/demo.gguf",
invalid: false,
message: null,
});
});
it("deduplicates model entries by path while keeping first occurrence", () => {
expect(
dedupeModels([
{ name: "demo.gguf", path: "D:/models/demo.gguf" },
{ name: "duplicate.gguf", path: "D:/models/demo.gguf" },
{ name: "other.gguf", path: "D:/models/other.gguf" },
]),
).toEqual([
{ name: "demo.gguf", path: "D:/models/demo.gguf" },
{ name: "other.gguf", path: "D:/models/other.gguf" },
]);
});
it("builds a trimmed local patch from the form state", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(snapshot.config, createFormState(), 32);
expect(patch).toMatchObject({
connection_mode: "local",
server_url: "https://api.example.com",
api_key: "sk-demo",
model_path: "D:/models/demo.gguf",
selected_model_id: null,
models_dir: "D:/models",
gpu_layers: 32,
ctx_size: 8192,
extra_model_paths: ["D:/models/demo.gguf"],
remote_presets: [],
hugging_face_token: "",
});
});
it("keeps saved local values untouched when editing remote mode", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(
{
...snapshot.config,
model_path: "D:/models/keep.gguf",
models_dir: "D:/models",
extra_model_paths: ["D:/models/keep.gguf"],
},
createFormState({
mode: "remote",
serverUrl: " https://api.example.com/ ",
apiKey: " remote-key ",
modelsDir: "",
selectedModel: "",
extraModels: [],
}),
16,
);
expect(patch).toMatchObject({
connection_mode: "remote",
server_url: "https://api.example.com",
api_key: "remote-key",
model_path: "D:/models/keep.gguf",
selected_model_id: null,
models_dir: "D:/models",
extra_model_paths: ["D:/models/keep.gguf"],
remote_presets: [],
hugging_face_token: "",
});
});
it("preserves selected model id, presets, and token when building a patch", () => {
const snapshot = createDefaultRuntimeSnapshot();
snapshot.config.selected_model_id = "managed:phi-4-mini";
snapshot.config.remote_presets = [
{
id: "openrouter",
label: "OpenRouter",
provider: "open_router",
base_url: "https://openrouter.ai/api/v1",
default_model: "openrouter/auto",
},
];
snapshot.config.hugging_face_token = "hf_demo";
const patch = createRuntimeConfigPatch(
snapshot.config,
{
...createFormState(),
selectedModelId: "managed:phi-4-mini",
},
64,
);
expect(patch.selected_model_id).toBe("managed:phi-4-mini");
expect(patch.remote_presets).toEqual(snapshot.config.remote_presets);
expect(patch.hugging_face_token).toBe("hf_demo");
});
it("writes the selected managed model id when the local selection comes from the model library", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(
snapshot.config,
{
...createFormState(),
selectedModel: "D:/managed/phi-4-mini.gguf",
selectedModelId: "managed:phi-4-mini",
},
64,
);
expect(patch.model_path).toBe("D:/managed/phi-4-mini.gguf");
expect(patch.selected_model_id).toBe("managed:phi-4-mini");
});
it("clears the selected managed model id when switching back to a manual path", () => {
const snapshot = createDefaultRuntimeSnapshot();
snapshot.config.selected_model_id = "managed:phi-4-mini";
const patch = createRuntimeConfigPatch(
snapshot.config,
{
...createFormState(),
selectedModel: "D:/models/manual.gguf",
selectedModelId: null,
},
64,
);
expect(patch.model_path).toBe("D:/models/manual.gguf");
expect(patch.selected_model_id).toBeNull();
});
it("explains why local runtime start is blocked", () => {
expect(
getLocalRuntimeStartReason({ mode: "local", binaryPath: null, selectedModel: "D:/models/demo.gguf" }),
).toContain("Build or bundle");
expect(
getLocalRuntimeStartReason({ mode: "local", binaryPath: "D:/bin/localllm.exe", selectedModel: "" }),
).toContain("Choose a model");
expect(
getLocalRuntimeStartReason({
mode: "local",
binaryPath: "D:/bin/localllm.exe",
selectedModel: "D:/models/demo.gguf",
}),
).toBe("");
});
it("normalizes model file labels from a path", () => {
expect(pathName("D:/models/demo.gguf")).toBe("demo.gguf");
expect(toModelFile("D:/models/demo.gguf")).toEqual({
name: "demo.gguf",
path: "D:/models/demo.gguf",
});
});
});
@@ -0,0 +1,138 @@
import type {
ConnectionMode,
RuntimeConfig,
RuntimeModelFile,
StartPolicy,
} from "./types";
function normalizeUrl(value: string): string {
const trimmed = value.trim();
return trimmed.replace(/\/+$/, "");
}
export interface RuntimeSettingsFormState {
mode: ConnectionMode;
startPolicy: StartPolicy;
serverUrl: string;
apiKey: string;
modelsDir: string;
selectedModel: string;
selectedModelId: string | null;
addr: string;
gpuLayers: number;
ctxSize: number;
extraModels: RuntimeModelFile[];
}
export function pathName(path: string | null | undefined): string {
if (!path) return "";
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || path;
}
export function toModelFile(path: string): RuntimeModelFile {
return { name: pathName(path) || path, path };
}
export function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] {
const seen = new Set<string>();
return models.filter((model) => {
if (seen.has(model.path)) return false;
seen.add(model.path);
return true;
});
}
export function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string {
const nextChoices = [...scanned, ...extra].map((model) => model.path);
if (current && (nextChoices.length === 0 || nextChoices.includes(current))) {
return current;
}
return scanned[0]?.path || extra[0]?.path || "";
}
export interface RuntimeDraftModelSelection {
nextModel: string;
invalid: boolean;
message: string | null;
}
export function resolveDraftModelSelection(
current: string,
scanned: RuntimeModelFile[],
extra: RuntimeModelFile[],
): RuntimeDraftModelSelection {
const nextChoices = [...scanned, ...extra].map((model) => model.path);
if (!current) {
return {
nextModel: scanned[0]?.path || extra[0]?.path || "",
invalid: false,
message: null,
};
}
if (nextChoices.length === 0 || nextChoices.includes(current)) {
return {
nextModel: current,
invalid: false,
message: null,
};
}
return {
nextModel: current,
invalid: true,
message: "This draft model is no longer available in the current library or folder scan.",
};
}
export function getLocalRuntimeStartReason(params: {
mode: ConnectionMode;
binaryPath: string | null;
selectedModel: string;
}): string {
const { mode, binaryPath, selectedModel } = params;
if (mode !== "local") {
return "Save mode before starting the local runtime.";
}
if (!binaryPath) {
return "Build or bundle the local API binary before starting.";
}
if (!selectedModel.trim()) {
return "Choose a model before starting the local runtime.";
}
return "";
}
export function createRuntimeConfigPatch(
snapshotConfig: RuntimeConfig,
form: RuntimeSettingsFormState,
maxLayers: number,
): RuntimeConfig {
return {
connection_mode: form.mode,
start_policy: form.startPolicy,
server_url: normalizeUrl(form.serverUrl),
api_key: form.apiKey.trim(),
models_dir: form.mode === "local" ? (form.modelsDir.trim() || null) : snapshotConfig.models_dir,
model_path: form.mode === "local" ? (form.selectedModel.trim() || null) : snapshotConfig.model_path,
addr: form.mode === "local" ? form.addr.trim() || snapshotConfig.addr : snapshotConfig.addr,
gpu_layers:
form.mode === "local"
? Math.max(0, Math.min(form.gpuLayers, maxLayers))
: snapshotConfig.gpu_layers,
ctx_size: form.mode === "local" ? Math.max(512, form.ctxSize || 4096) : snapshotConfig.ctx_size,
selected_model_id: form.mode === "local" ? form.selectedModelId ?? null : snapshotConfig.selected_model_id ?? null,
extra_model_paths:
form.mode === "local"
? form.extraModels.map((model) => model.path)
: snapshotConfig.extra_model_paths,
remote_presets: snapshotConfig.remote_presets ?? [],
hugging_face_token: snapshotConfig.hugging_face_token ?? "",
};
}
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { createRuntimeLifecycleResult, getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & {
config?: Partial<RuntimeConfig>;
};
function createSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
const { config: configOverrides, ...snapshotOverrides } = overrides;
const defaultConfig: RuntimeConfig = {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
hugging_face_token: "",
};
return {
status: "stopped",
ready: false,
process_running: false,
resolved_url: "http://127.0.0.1:8080",
binary_path: null,
last_error: null,
active_command: "idle",
last_command_result: createRuntimeLifecycleResult(),
config: {
...defaultConfig,
...(configOverrides ?? {}),
},
...snapshotOverrides,
};
}
describe("getRuntimeConnection", () => {
it("prefers the resolved local runtime url in local mode", () => {
const connection = getRuntimeConnection(createSnapshot({
resolved_url: "http://127.0.0.1:9000",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "http://example.com:9999",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:9000",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(connection.serverUrl).toBe("http://127.0.0.1:9000");
expect(connection.mode).toBe("local");
});
it("uses remote settings in remote mode", () => {
const connection = getRuntimeConnection(createSnapshot({
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "sk-demo",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(connection.serverUrl).toBe("https://api.example.com");
expect(connection.apiKey).toBe("sk-demo");
expect(connection.mode).toBe("remote");
});
});
describe("getRuntimeStateLabel", () => {
it("reports ready snapshots as ready", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
});
it("reports local background start progress", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
});
it("reports remote mode without runtime ownership", () => {
expect(getRuntimeStateLabel(createSnapshot({
status: "stopped",
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}))).toBe("Remote API");
});
});
describe("getRuntimeStartGuard", () => {
it("requires a saved model before starting the local runtime", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(false);
expect(guard.reason.toLowerCase()).toContain("model");
});
it("allows starting when the saved local runtime config is complete", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: "D:/models/demo.gguf",
models_dir: "D:/models",
addr: "127.0.0.1:8080",
gpu_layers: 24,
ctx_size: 8192,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(true);
expect(guard.reason).toBe("");
});
it("never offers managed start controls in remote mode", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "sk-demo",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(false);
expect(guard.reason.toLowerCase()).toContain("remote");
});
});
@@ -0,0 +1,446 @@
import type {
ConnectionMode,
RuntimeApplyImpact,
RuntimeConfig,
RuntimeConnection,
RuntimeDraftFieldKey,
RuntimeLifecycleCommand,
RuntimeLifecycleResult,
RuntimeModelFile,
RuntimeSnapshot,
RuntimeWorkspaceHealth,
RuntimeWorkspaceHealthTone,
RuntimeWorkspaceSummary,
} from "./types";
import { scanRuntimeModels } from "./api";
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
// === Phase 4: Source-Mode Validation Types ===
export type ValidationSeverity = "error" | "warning";
export interface FieldValidation {
valid: boolean;
severity: ValidationSeverity;
message: string | null;
}
export interface SourceModeValidation {
mode: ConnectionMode;
valid: boolean;
fieldValidations: Record<string, FieldValidation>;
}
export interface PathValidation {
path: string;
exists: boolean;
isValid: boolean;
message: string | null;
}
// === Phase 4: Source-Mode Validation Functions ===
/**
* Validates source-mode configuration based on connection mode.
* For local mode, requires model_path. For remote mode, requires server_url.
*/
export function validateSourceMode(config: RuntimeConfig): SourceModeValidation {
const fieldValidations: Record<string, FieldValidation> = {};
let valid = true;
if (config.connection_mode === "local") {
// Validate local mode requirements
if (!config.model_path) {
fieldValidations.model_path = {
valid: false,
severity: "error",
message: "Select a model file for local runtime",
};
valid = false;
}
// models_dir is optional for local mode
} else if (config.connection_mode === "remote") {
// Validate remote mode requirements
if (!config.server_url) {
fieldValidations.server_url = {
valid: false,
severity: "error",
message: "Server URL is required for remote runtime",
};
valid = false;
}
// Validate URL format for remote mode
if (config.server_url) {
try {
const url = new URL(config.server_url);
if (!["http:", "https:"].includes(url.protocol)) {
fieldValidations.server_url = {
valid: false,
severity: "error",
message: "Server URL must use http or https protocol",
};
valid = false;
}
} catch {
fieldValidations.server_url = {
valid: false,
severity: "error",
message: "Invalid URL format",
};
valid = false;
}
}
}
return {
mode: config.connection_mode,
valid,
fieldValidations,
};
}
/**
* Validates a model path. For now, returns pending status.
* Actual existence check happens via scan in plan 04-02.
*/
export function validateModelPath(path: string | null): PathValidation {
if (!path) {
return { path: "", exists: false, isValid: false, message: null };
}
// For now, return valid - actual existence check happens via scan
return { path, exists: true, isValid: true, message: null };
}
/**
* Get field keys that require validation based on connection mode
*/
export function getValidatedFields(mode: ConnectionMode): RuntimeDraftFieldKey[] {
if (mode === "local") {
return ["model_path", "models_dir"];
}
return ["server_url", "api_key"];
}
/**
* Check if draft has any validation errors
*/
export function hasValidationErrors(fieldValidations: Record<string, FieldValidation>): boolean {
return Object.values(fieldValidations).some((fv) => !fv.valid);
}
// === Phase 4: Path and Model File Validation (Async) ===
export interface DirectoryValidation {
valid: boolean;
modelCount: number;
error: string | null;
}
export interface ModelFileValidation {
valid: boolean;
exists: boolean;
error: string | null;
}
const RESTART_REQUIRED_FIELDS: Array<keyof RuntimeConfig> = [
"connection_mode",
"model_path",
"addr",
"gpu_layers",
"ctx_size",
];
function normalizeUrl(value: string | null | undefined): string {
const trimmed = (value || "").trim();
return trimmed ? trimmed.replace(/\/+$/, "") : "";
}
export function getRuntimeConnection(snapshot: RuntimeSnapshot): RuntimeConnection {
if (snapshot.config.connection_mode === "remote") {
return {
mode: "remote",
serverUrl: normalizeUrl(snapshot.config.server_url),
apiKey: snapshot.config.api_key || "",
managed: false,
};
}
return {
mode: "local",
serverUrl: normalizeUrl(snapshot.resolved_url) || DEFAULT_LOCAL_URL,
apiKey: snapshot.config.api_key || "",
managed: true,
};
}
export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
if (snapshot.config.connection_mode === "remote") {
return "Remote API";
}
switch (snapshot.status) {
case "ready":
return "Ready";
case "starting":
return "Starting local API";
case "stopping":
return "Stopping local API";
case "error":
return "Local API error";
case "degraded":
return "Attention needed";
default:
return "Local API offline";
}
}
export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "success" | "warning" | "danger" {
if (snapshot.config.connection_mode === "remote") {
return "neutral";
}
switch (snapshot.status) {
case "ready":
return "success";
case "starting":
case "stopping":
case "degraded":
return "warning";
case "error":
return "danger";
default:
return "neutral";
}
}
export interface RuntimeStartGuard {
canStart: boolean;
reason: string;
}
export interface RuntimeApplyImpactOptions {
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
processRunning: boolean;
}
export function createRuntimeLifecycleResult(
command: RuntimeLifecycleCommand = "idle",
message = "Runtime idle.",
outcome: RuntimeLifecycleResult["outcome"] = "succeeded",
): RuntimeLifecycleResult {
return {
command,
outcome,
message,
};
}
export function getRuntimeApplyImpact({
savedBaseline,
draftConfig,
processRunning,
}: RuntimeApplyImpactOptions): RuntimeApplyImpact {
if (savedBaseline.connection_mode !== draftConfig.connection_mode) {
return "save_before_apply";
}
const needsRestart = RESTART_REQUIRED_FIELDS.some((field) => savedBaseline[field] !== draftConfig[field]);
if (processRunning && needsRestart) {
return "restart_required";
}
return "immediate";
}
export function getRuntimeStartGuard(snapshot: RuntimeSnapshot): RuntimeStartGuard {
if (snapshot.config.connection_mode === "remote") {
return {
canStart: false,
reason: "Remote mode uses an external API and does not launch the local runtime.",
};
}
if (!snapshot.binary_path) {
return {
canStart: false,
reason: "Build or bundle the local API binary before starting the managed runtime.",
};
}
if (!snapshot.config.model_path?.trim()) {
return {
canStart: false,
reason: "Choose a local model before starting the managed runtime.",
};
}
return { canStart: true, reason: "" };
}
export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
return {
status: "stopped",
ready: false,
process_running: false,
resolved_url: DEFAULT_LOCAL_URL,
binary_path: null,
last_error: null,
active_command: "idle",
last_command_result: createRuntimeLifecycleResult(),
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
hugging_face_token: "",
},
};
}
// === Phase 3: Unified Runtime Workspace Helpers ===
function getModelLabel(modelPath: string | null): string | null {
if (!modelPath) return null;
const parts = modelPath.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || null;
}
export function getRuntimeWorkspaceSummary(snapshot: RuntimeSnapshot): RuntimeWorkspaceSummary {
const connection = getRuntimeConnection(snapshot);
const statusLabel = getRuntimeStateLabel(snapshot);
const isRemote = snapshot.config.connection_mode === "remote";
return {
statusLabel,
modeLabel: isRemote ? "remote" : "local",
endpointLabel: connection.serverUrl,
modelLabel: getModelLabel(snapshot.config.model_path),
isReady: snapshot.ready,
isRunning: snapshot.process_running,
hasError: snapshot.status === "error" || (snapshot.last_error !== null && snapshot.last_error.code !== "poll_warning"),
};
}
function deriveHealthTone(snapshot: RuntimeSnapshot): RuntimeWorkspaceHealthTone {
if (snapshot.config.connection_mode === "remote") {
return "neutral";
}
switch (snapshot.status) {
case "ready":
return "success";
case "starting":
case "stopping":
case "degraded":
return "warning";
case "error":
return "danger";
default:
return "neutral";
}
}
function deriveHealthHeadline(snapshot: RuntimeSnapshot): string {
if (snapshot.config.connection_mode === "remote") {
return "Remote API connected";
}
switch (snapshot.status) {
case "ready":
return "Local API ready";
case "starting":
return "Starting local API...";
case "stopping":
return "Stopping local API...";
case "degraded":
return "Attention needed";
case "error":
return "Local API error";
default:
return "Local API offline";
}
}
function deriveHealthDetail(snapshot: RuntimeSnapshot): string {
if (snapshot.config.connection_mode === "remote") {
return snapshot.config.server_url || "Configure remote endpoint";
}
if (snapshot.last_error) {
return snapshot.last_error.message;
}
if (snapshot.status === "ready" && snapshot.process_running) {
return snapshot.resolved_url || "http://127.0.0.1:8080";
}
if (snapshot.status === "stopped") {
return "Start the local API to begin";
}
return "";
}
export function getRuntimeHealthSummary(snapshot: RuntimeSnapshot): RuntimeWorkspaceHealth {
return {
headline: deriveHealthHeadline(snapshot),
detail: deriveHealthDetail(snapshot),
tone: deriveHealthTone(snapshot),
};
}
// === Phase 4: Async Path and Model Validation ===
/**
* Validate models directory by scanning it
*/
export async function validateModelsDirectory(
path: string | null,
): Promise<DirectoryValidation> {
if (!path) {
return { valid: false, modelCount: 0, error: "No models directory selected" };
}
try {
const models = await scanRuntimeModels(path);
return {
valid: true,
modelCount: models.length,
error: null,
};
} catch (e) {
return {
valid: false,
modelCount: 0,
error: e instanceof Error ? e.message : String(e),
};
}
}
/**
* Validate that a model path exists in the scanned models list
*/
export function validateModelFile(
path: string | null,
scannedModels: RuntimeModelFile[],
): ModelFileValidation {
if (!path) {
return { valid: false, exists: false, error: "No model selected" };
}
const exists = scannedModels.some((m) => m.path === path);
return {
valid: exists,
exists,
error: exists ? null : "Model file not found in current models directory",
};
}
+180
View File
@@ -0,0 +1,180 @@
export type ConnectionMode = "local" | "remote";
export type StartPolicy = "manual" | "on_app_launch";
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
export type RuntimeLifecycleCommand = "idle" | "save" | "start" | "stop" | "save_and_start";
export type RuntimeLifecycleOutcome = "succeeded" | "blocked" | "failed";
export type RuntimeApplyImpact = "immediate" | "restart_required" | "save_before_apply";
export type RemoteProviderKind = "open_ai" | "open_router" | "anthropic" | "custom" | "unknown";
export interface RemotePreset {
id: string;
label: string;
provider: RemoteProviderKind;
base_url: string;
default_model: string;
}
export interface RuntimeConfig {
connection_mode: ConnectionMode;
start_policy: StartPolicy;
server_url: string;
api_key: string;
model_path: string | null;
models_dir: string | null;
addr: string;
gpu_layers: number;
ctx_size: number;
extra_model_paths: string[];
selected_model_id: string | null;
remote_presets: RemotePreset[];
hugging_face_token: string;
}
export interface RuntimeErrorInfo {
code: string;
message: string;
}
export interface RuntimeLifecycleResult {
command: RuntimeLifecycleCommand;
outcome: RuntimeLifecycleOutcome;
message: string;
}
export interface RuntimeSnapshot {
status: RuntimeStatus;
ready: boolean;
process_running: boolean;
resolved_url: string;
binary_path: string | null;
last_error: RuntimeErrorInfo | null;
active_command: RuntimeLifecycleCommand;
last_command_result: RuntimeLifecycleResult;
config: RuntimeConfig;
}
export const RUNTIME_DRAFT_FIELD_KEYS = [
"models_dir",
"model_path",
"selected_model_id",
"server_url",
"api_key",
] as const;
export type RuntimeDraftFieldKey = (typeof RUNTIME_DRAFT_FIELD_KEYS)[number];
export interface RuntimeDraftFieldMeta {
invalid: boolean;
pending: boolean;
message: string | null;
}
export type RuntimeDraftFieldState = Partial<Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta>>;
export interface RuntimeDraftState {
liveSnapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
isDirty: boolean;
isOpen: boolean;
fields: RuntimeDraftFieldState;
// Phase 4: Scanned models for validation
scannedModels: RuntimeModelFile[];
isScanning: boolean;
}
export function createRuntimeDraftFieldState(): Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta> {
return {
models_dir: { invalid: false, pending: false, message: null },
model_path: { invalid: false, pending: false, message: null },
selected_model_id: { invalid: false, pending: false, message: null },
server_url: { invalid: false, pending: false, message: null },
api_key: { invalid: false, pending: false, message: null },
};
}
export function createRuntimeDraftState(snapshot: RuntimeSnapshot): RuntimeDraftState {
return {
liveSnapshot: snapshot,
savedBaseline: snapshot.config,
draftConfig: snapshot.config,
isDirty: false,
isOpen: false,
fields: createRuntimeDraftFieldState(),
// Phase 4: Scanned models for validation
scannedModels: [],
isScanning: false,
};
}
export interface RuntimeConnection {
mode: ConnectionMode;
serverUrl: string;
apiKey: string;
managed: boolean;
}
export interface RuntimeModelFile {
name: string;
path: string;
}
export interface RuntimePickDirResult {
dir: string;
models: RuntimeModelFile[];
}
export interface RuntimeGpuInfo {
name: string;
vram_mib: number;
recommended_layers: number;
}
export interface HuggingFaceVerifiedFile {
path: string;
size: number | null;
quantization: string | null;
}
export interface HuggingFaceModelResult {
repo_id: string;
author: string;
verified_files: HuggingFaceVerifiedFile[];
}
export interface ModelLibraryEntry {
id: string;
label: string;
source: string;
path: string;
}
export interface ModelLibraryState {
entries: ModelLibraryEntry[];
managed_dir: string;
}
// === Phase 3: Unified Runtime Workspace Types ===
export type RuntimeWorkspaceHealthTone = "neutral" | "success" | "warning" | "danger";
export interface RuntimeWorkspaceSectionValue {
label: string;
value: string;
}
export interface RuntimeWorkspaceHealth {
headline: string;
detail: string;
tone: RuntimeWorkspaceHealthTone;
}
export interface RuntimeWorkspaceSummary {
statusLabel: string;
modeLabel: "local" | "remote";
endpointLabel: string;
modelLabel: string | null;
isReady: boolean;
isRunning: boolean;
hasError: boolean;
}
@@ -0,0 +1,541 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, scanRuntimeModels, startRuntime, stopRuntime } from "./api";
import {
createDefaultRuntimeSnapshot,
createRuntimeLifecycleResult,
getRuntimeApplyImpact,
getRuntimeConnection,
getRuntimeHealthSummary,
getRuntimeWorkspaceSummary,
validateModelFile,
validateModelsDirectory,
validateSourceMode,
} from "./runtime-utils";
import { createRuntimeDraftFieldState, createRuntimeDraftState } from "./types";
import type {
RuntimeApplyImpact,
RuntimeConfig,
RuntimeDraftFieldKey,
RuntimeDraftFieldMeta,
RuntimeDraftState,
RuntimeLifecycleCommand,
RuntimeLifecycleResult,
RuntimeModelFile,
RuntimeSnapshot,
RuntimeWorkspaceHealth,
RuntimeWorkspaceSummary,
} from "./types";
function areConfigsEqual(left: RuntimeConfig, right: RuntimeConfig): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function cloneConfig(config: RuntimeConfig): RuntimeConfig {
return {
...config,
extra_model_paths: [...config.extra_model_paths],
remote_presets: config.remote_presets.map((preset) => ({ ...preset })),
};
}
function applyOperationalSnapshot(current: RuntimeSnapshot, next: RuntimeSnapshot): RuntimeSnapshot {
return {
...current,
status: next.status,
ready: next.ready,
process_running: next.process_running,
resolved_url: next.resolved_url,
binary_path: next.binary_path,
last_error: next.last_error,
active_command: next.active_command,
last_command_result: next.last_command_result,
};
}
function normalizeSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
return {
...createDefaultRuntimeSnapshot(),
...snapshot,
config: {
...createDefaultRuntimeSnapshot().config,
...snapshot.config,
},
last_command_result:
snapshot.last_command_result ?? createRuntimeLifecycleResult(snapshot.active_command ?? "idle"),
active_command: snapshot.active_command ?? "idle",
};
}
function reconcileDraftState(current: RuntimeDraftState, next: RuntimeSnapshot): RuntimeDraftState {
const normalizedNext = normalizeSnapshot(next);
if (current.isDirty) {
return {
...current,
liveSnapshot: applyOperationalSnapshot(current.liveSnapshot, normalizedNext),
};
}
// Run source-mode validation when reconciling to baseline
const validation = validateSourceMode(normalizedNext.config);
const updatedFields = createRuntimeDraftFieldState();
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
updatedFields[fieldName as RuntimeDraftFieldKey] = {
invalid: !fieldValidation.valid,
pending: false,
message: fieldValidation.message,
};
}
return {
...current,
liveSnapshot: normalizedNext,
savedBaseline: cloneConfig(normalizedNext.config),
draftConfig: cloneConfig(normalizedNext.config),
fields: updatedFields,
// Preserve scanned models state during reconciliation
scannedModels: current.scannedModels,
isScanning: current.isScanning,
};
}
function blockWhileLifecycleBusy(
snapshot: RuntimeSnapshot,
command: RuntimeLifecycleCommand,
): RuntimeSnapshot | null {
if (snapshot.active_command === "idle") {
return null;
}
const blockedBy = snapshot.active_command;
const message =
blockedBy === "start"
? "Local API start already in progress."
: blockedBy === "stop"
? "Local API stop already in progress."
: blockedBy === "save"
? "Runtime settings save already in progress."
: "Runtime save and start already in progress.";
return {
...snapshot,
last_command_result: createRuntimeLifecycleResult(command, message, "blocked"),
};
}
function startLifecycleSnapshot(
snapshot: RuntimeSnapshot,
command: RuntimeLifecycleCommand,
status: RuntimeSnapshot["status"],
): RuntimeSnapshot {
return {
...snapshot,
status,
process_running: command === "start" ? true : snapshot.process_running,
ready: false,
last_error: null,
active_command: command,
};
}
function finalizeLifecycleSnapshot(
fallbackCommand: RuntimeLifecycleCommand,
fallbackMessage: string,
next: RuntimeSnapshot,
): RuntimeSnapshot {
const normalizedNext = normalizeSnapshot(next);
if (normalizedNext.last_command_result.command !== "idle") {
return normalizedNext;
}
return {
...normalizedNext,
last_command_result: createRuntimeLifecycleResult(fallbackCommand, fallbackMessage),
};
}
export function useRuntimeManager() {
const [draftState, setDraftState] = useState<RuntimeDraftState>(() =>
createRuntimeDraftState(createDefaultRuntimeSnapshot()),
);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
const next = await getRuntimeSnapshot();
setDraftState((current) => reconcileDraftState(current, next));
return next;
}, []);
useEffect(() => {
let active = true;
bootstrapRuntime()
.then((next) => {
if (active) {
setDraftState((current) => reconcileDraftState(current, next));
setLoading(false);
}
})
.catch(() => {
if (active) {
setLoading(false);
}
});
const id = window.setInterval(() => {
refresh().catch(() => undefined);
}, 3000);
return () => {
active = false;
window.clearInterval(id);
};
}, [refresh]);
const openDraft = useCallback(() => {
setDraftState((current) => {
const draftConfig = current.isDirty ? current.draftConfig : cloneConfig(current.savedBaseline);
// Run source-mode validation when opening draft
const validation = validateSourceMode(draftConfig);
// Initialize field validations based on validation results
const updatedFields = { ...current.fields };
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
updatedFields[fieldName as RuntimeDraftFieldKey] = {
invalid: !fieldValidation.valid,
pending: false,
message: fieldValidation.message,
};
}
return {
...current,
isOpen: true,
draftConfig,
fields: updatedFields,
};
});
}, []);
const closeDraft = useCallback(() => {
setDraftState((current) => ({
...current,
isOpen: false,
}));
}, []);
const resetDraft = useCallback(() => {
setDraftState((current) => {
const resetConfig = cloneConfig(current.savedBaseline);
// Run source-mode validation when resetting
const validation = validateSourceMode(resetConfig);
// Initialize field validations based on validation results
const updatedFields = createRuntimeDraftFieldState();
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
updatedFields[fieldName as RuntimeDraftFieldKey] = {
invalid: !fieldValidation.valid,
pending: false,
message: fieldValidation.message,
};
}
return {
...current,
draftConfig: resetConfig,
isDirty: false,
fields: updatedFields,
};
});
}, []);
const updateDraft = useCallback((patch: Partial<RuntimeConfig>) => {
setDraftState((current) => {
const nextDraft = {
...current.draftConfig,
...patch,
};
return {
...current,
isOpen: true,
isDirty: !areConfigsEqual(nextDraft, current.savedBaseline),
draftConfig: nextDraft,
};
});
}, []);
const updateDraftField = useCallback(<K extends RuntimeDraftFieldKey>(field: K, value: RuntimeConfig[K]) => {
setDraftState((current) => {
const nextDraft = {
...current.draftConfig,
[field]: value,
};
// Run source-mode validation
const validation = validateSourceMode(nextDraft);
// Update field validations based on validation results
const updatedFields = { ...current.fields };
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
updatedFields[fieldName as RuntimeDraftFieldKey] = {
invalid: !fieldValidation.valid,
pending: false,
message: fieldValidation.message,
};
}
return {
...current,
isOpen: true,
isDirty: !areConfigsEqual(nextDraft, current.savedBaseline),
draftConfig: nextDraft,
fields: updatedFields,
};
});
}, []);
const updateDraftFieldMeta = useCallback(
(field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => {
setDraftState((current) => ({
...current,
fields: {
...current.fields,
[field]: {
...current.fields[field],
invalid: false,
pending: false,
message: null,
...patch,
},
},
}));
},
[],
);
// Phase 4: Model scanning for validation
const scanModels = useCallback(async (dirPath: string | null) => {
if (!dirPath) {
setDraftState((current) => ({
...current,
scannedModels: [],
isScanning: false,
}));
return;
}
// Set scanning state first
setDraftState((current) => ({
...current,
isScanning: true,
}));
try {
const models = await scanRuntimeModels(dirPath);
// Now update with results
setDraftState((current) => {
const modelPathInvalid = current.draftConfig.model_path
? !models.some((m) => m.path === current.draftConfig.model_path)
: false;
const modelPathMessage = current.draftConfig.model_path
? models.some((m) => m.path === current.draftConfig.model_path)
? null
: "Model file not found in current models directory"
: null;
return {
...current,
scannedModels: models,
isScanning: false,
// Validate model_path against scanned models
fields: {
...current.fields,
model_path: {
invalid: modelPathInvalid,
pending: false,
message: modelPathMessage,
},
},
};
});
} catch (error) {
setDraftState((current) => ({
...current,
scannedModels: [],
isScanning: false,
fields: {
...current.fields,
models_dir: {
invalid: true,
pending: false,
message: error instanceof Error ? error.message : String(error),
},
},
}));
}
}, []);
const commitSavedSnapshot = useCallback((next: RuntimeSnapshot, keepOpen: boolean) => {
setDraftState((current) => ({
...current,
liveSnapshot: next,
savedBaseline: cloneConfig(next.config),
draftConfig: cloneConfig(next.config),
isDirty: false,
isOpen: keepOpen,
fields: createRuntimeDraftFieldState(),
}));
}, []);
const saveDraft = useCallback(async () => {
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "save");
if (blockedSnapshot) {
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
return blockedSnapshot;
}
setBusy(true);
setDraftState((current) => ({
...current,
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "save", current.liveSnapshot.status),
}));
try {
const next = await saveRuntimeConfig(draftState.draftConfig);
const resolvedNext = finalizeLifecycleSnapshot("save", "Save completed.", next);
commitSavedSnapshot(resolvedNext, true);
return resolvedNext;
} finally {
setBusy(false);
}
}, [commitSavedSnapshot, draftState.draftConfig]);
const saveConfig = useCallback(async (patch: Partial<RuntimeConfig>) => {
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "save");
if (blockedSnapshot) {
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
return blockedSnapshot;
}
setBusy(true);
setDraftState((current) => ({
...current,
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "save", current.liveSnapshot.status),
}));
try {
const next = await saveRuntimeConfig({ ...draftState.draftConfig, ...patch });
const resolvedNext = finalizeLifecycleSnapshot("save", "Save completed.", next);
commitSavedSnapshot(resolvedNext, draftState.isOpen);
return resolvedNext;
} finally {
setBusy(false);
}
}, [commitSavedSnapshot, draftState.draftConfig, draftState.isOpen]);
const startLocal = useCallback(async () => {
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "start");
if (blockedSnapshot) {
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
return blockedSnapshot;
}
setBusy(true);
setDraftState((current) => ({
...current,
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "start", "starting"),
}));
try {
const next = await startRuntime();
const resolvedNext = finalizeLifecycleSnapshot("start", "Local API started.", next);
setDraftState((current) => reconcileDraftState(current, resolvedNext));
return resolvedNext;
} finally {
setBusy(false);
}
}, [draftState.liveSnapshot]);
const stopLocal = useCallback(async () => {
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "stop");
if (blockedSnapshot) {
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
return blockedSnapshot;
}
setBusy(true);
setDraftState((current) => ({
...current,
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "stop", "stopping"),
}));
try {
const next = await stopRuntime();
const resolvedNext = finalizeLifecycleSnapshot("stop", "Local API stopped.", next);
setDraftState((current) => reconcileDraftState(current, resolvedNext));
return resolvedNext;
} finally {
setBusy(false);
}
}, [draftState.liveSnapshot]);
const connection = useMemo(() => getRuntimeConnection(draftState.liveSnapshot), [draftState.liveSnapshot]);
const activeLifecycleCommand = draftState.liveSnapshot.active_command;
const lastLifecycleResult: RuntimeLifecycleResult = draftState.liveSnapshot.last_command_result;
const lifecycleBusy = activeLifecycleCommand !== "idle";
const applyImpact: RuntimeApplyImpact = useMemo(
() =>
getRuntimeApplyImpact({
savedBaseline: draftState.savedBaseline,
draftConfig: draftState.draftConfig,
processRunning: draftState.liveSnapshot.process_running,
}),
[draftState.draftConfig, draftState.liveSnapshot.process_running, draftState.savedBaseline],
);
// Phase 3: Unified workspace contract
const workspaceSummary: RuntimeWorkspaceSummary = useMemo(
() => getRuntimeWorkspaceSummary(draftState.liveSnapshot),
[draftState.liveSnapshot],
);
const workspaceHealth: RuntimeWorkspaceHealth = useMemo(
() => getRuntimeHealthSummary(draftState.liveSnapshot),
[draftState.liveSnapshot],
);
return {
snapshot: draftState.liveSnapshot,
liveSnapshot: draftState.liveSnapshot,
savedBaseline: draftState.savedBaseline,
draftConfig: draftState.draftConfig,
draftDirty: draftState.isDirty,
draftOpen: draftState.isOpen,
draftFields: draftState.fields,
connection,
loading,
busy,
activeLifecycleCommand,
lastLifecycleResult,
lifecycleBusy,
applyImpact,
// Phase 3: Unified workspace contract
workspaceSummary,
workspaceHealth,
refresh,
openDraft,
closeDraft,
resetDraft,
updateDraft,
updateDraftField,
updateDraftFieldMeta,
saveDraft,
saveConfig,
startLocal,
stopLocal,
// Phase 4: Model scanning for validation
scannedModels: draftState.scannedModels,
isScanningModels: draftState.isScanning,
scanModels,
};
}
@@ -0,0 +1,82 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeStatus } from "./types";
import { useRuntimeReadyModelRefresh } from "./useRuntimeReadyModelRefresh";
function Harness({ status, onRefresh }: { status: RuntimeStatus; onRefresh: () => void }) {
useRuntimeReadyModelRefresh(status, onRefresh);
return null;
}
describe("useRuntimeReadyModelRefresh", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("refreshes once when the runtime transitions into ready", async () => {
const onRefresh = vi.fn();
await act(async () => {
root.render(<Harness status="stopped" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="ready" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="ready" onRefresh={onRefresh} />);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("refreshes again after leaving ready and coming back", async () => {
const onRefresh = vi.fn();
await act(async () => {
root.render(<Harness status="ready" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="degraded" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="ready" onRefresh={onRefresh} />);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("ignores non-ready transitions", async () => {
const onRefresh = vi.fn();
await act(async () => {
root.render(<Harness status="starting" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="stopping" onRefresh={onRefresh} />);
});
expect(onRefresh).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,14 @@
import { useEffect, useRef } from "react";
import type { RuntimeStatus } from "./types";
export function useRuntimeReadyModelRefresh(status: RuntimeStatus, onRefresh: () => void) {
const previousStatus = useRef<RuntimeStatus>(status);
useEffect(() => {
if (status === "ready" && previousStatus.current !== "ready") {
onRefresh();
}
previousStatus.current = status;
}, [status, onRefresh]);
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { getNextSelectedModel } from "./model-selection";
const models = [
{ id: "alpha", object: "model", created: 1, owned_by: "me" },
{ id: "beta", object: "model", created: 2, owned_by: "me" },
];
describe("getNextSelectedModel", () => {
it("keeps the current selection when it still exists", () => {
expect(getNextSelectedModel("beta", models)).toBe("beta");
});
it("falls back to the first model when the selection is missing", () => {
expect(getNextSelectedModel("missing", models)).toBe("alpha");
});
it("clears the selection when the model list becomes empty", () => {
expect(getNextSelectedModel("alpha", [])).toBe("");
});
});
+13
View File
@@ -0,0 +1,13 @@
import type { ModelInfo } from "../lib/types";
export function getNextSelectedModel(current: string, models: ModelInfo[]): string {
if (models.length === 0) {
return "";
}
if (current && models.some((model) => model.id === current)) {
return current;
}
return models[0]?.id || "";
}
+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 };
}
+43
View File
@@ -0,0 +1,43 @@
import { useState, useEffect, useCallback } from "react";
import type { ModelInfo, ServerStatus } from "../lib/types";
import { getNextSelectedModel } from "./model-selection";
export function useModels(server: string, apiKey: string, enabled = true) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [status, setStatus] = useState<ServerStatus>("loading");
const refresh = useCallback(async () => {
if (!enabled || !server.trim()) {
setModels([]);
setStatus("error");
return;
}
setStatus("loading");
try {
const headers: Record<string, string> = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${server}/v1/models`, { headers });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const list: ModelInfo[] = data.data || [];
setModels(list);
setSelectedModel((current) => getNextSelectedModel(current, list));
setStatus("ok");
} catch {
setModels([]);
setStatus("error");
}
}, [enabled, server, apiKey]);
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: #0a1016;
--bg2: #0f1721;
--bg3: #14202d;
--bg4: #182636;
--bg5: #203246;
--border: #243241;
--border2: #344759;
--text: #edf5ff;
--text2: #93a8bc;
--text3: #5d7288;
--accent: #48a6ff;
--accent2: #2f8ae3;
--accent-glow: rgba(72,166,255,0.16);
--user-bg: #10263f;
--user-border: #214468;
--asst-bg: #101924;
--asst-border: #243241;
--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: "Segoe UI Variable", "SF Pro Display", "IBM Plex Sans", "Inter", 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; }
+59
View File
@@ -0,0 +1,59 @@
export interface ParsedAssistantResponse {
rawContent: string;
content: string;
reasoning: string;
thinkingOpen: boolean;
}
const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";
export function buildChatTitle(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return "New Chat";
return normalized.slice(0, 50) + (normalized.length > 50 ? "..." : "");
}
export function parseAssistantResponse(rawContent: string): ParsedAssistantResponse {
let cursor = 0;
let thinkingOpen = false;
let content = "";
let reasoning = "";
while (cursor < rawContent.length) {
if (!thinkingOpen) {
const start = rawContent.indexOf(THINK_OPEN, cursor);
if (start === -1) {
content += rawContent.slice(cursor);
break;
}
content += rawContent.slice(cursor, start);
cursor = start + THINK_OPEN.length;
thinkingOpen = true;
continue;
}
const end = rawContent.indexOf(THINK_CLOSE, cursor);
if (end === -1) {
reasoning += rawContent.slice(cursor);
cursor = rawContent.length;
break;
}
reasoning += rawContent.slice(cursor, end);
cursor = end + THINK_CLOSE.length;
thinkingOpen = false;
}
const normalizedContent = reasoning.trim().length > 0
? content.replace(/^\s+/, "")
: content;
return {
rawContent,
content: normalizedContent.trimEnd(),
reasoning: reasoning.trim(),
thinkingOpen,
};
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { DB } from "./db";
interface FakeStatementRow {
[key: string]: unknown;
}
function createStatement(rows: FakeStatementRow[]) {
let index = -1;
return {
bind: vi.fn(),
step: vi.fn(() => {
index += 1;
return index < rows.length;
}),
getAsObject: vi.fn(() => rows[index] ?? {}),
free: vi.fn(),
};
}
describe("DB.insert", () => {
it("normalizes string rowids returned by sql.js", () => {
const prepare = vi.fn((sql: string) => {
if (sql.includes("last_insert_rowid")) {
return createStatement([{ id: "7" }]);
}
throw new Error(`unexpected query: ${sql}`);
});
const db = new DB({
run: vi.fn(),
prepare,
export: vi.fn(() => new Uint8Array([1, 2, 3])),
close: vi.fn(),
});
expect(db.insert("INSERT INTO chats (title) VALUES (?)", ["hello"])).toBe(7);
});
it("falls back to MAX(id) when last_insert_rowid is invalid", () => {
const prepare = vi.fn((sql: string) => {
if (sql.includes("last_insert_rowid")) {
return createStatement([{ id: 0 }]);
}
if (sql.includes("MAX(id)")) {
return createStatement([{ id: 12 }]);
}
throw new Error(`unexpected query: ${sql}`);
});
const db = new DB({
run: vi.fn(),
prepare,
export: vi.fn(() => new Uint8Array([1, 2, 3])),
close: vi.fn(),
});
expect(db.insert("INSERT INTO chats (title) VALUES (?)", ["hello"])).toBe(12);
expect(prepare).toHaveBeenCalledWith("SELECT CAST(MAX(id) AS INTEGER) as id FROM chats");
});
});
+201
View File
@@ -0,0 +1,201 @@
// 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[] = []) {
const shortSql = sql.trim().replace(/\s+/g, " ").slice(0, 80);
console.debug(`[DB] run: ${shortSql}`, params.length ? params : "");
this.inner.run(sql, params);
this.persist();
}
/** Insert a row and return its auto-generated id, reading rowid BEFORE persist(). */
insert(sql: string, params: unknown[] = []): number {
const shortSql = sql.trim().replace(/\s+/g, " ").slice(0, 80);
console.debug(`[DB] insert: ${shortSql}`, params.length ? params : "");
this.inner.run(sql, params);
const rawId = this.query<{ id: unknown }>("SELECT CAST(last_insert_rowid() AS INTEGER) as id")[0]?.id;
const id = normalizeInsertId(rawId) ?? this.getInsertIdFallback(sql);
console.debug(`[DB] insert: got rowid=${id}`);
this.persist();
return id;
}
private getInsertIdFallback(sql: string): number {
const table = extractInsertTable(sql);
if (table) {
const fallback = normalizeInsertId(
this.query<{ id: unknown }>(`SELECT CAST(MAX(id) AS INTEGER) as id FROM ${table}`)[0]?.id,
);
if (fallback !== null) {
console.warn(`[DB] insert: recovered rowid=${fallback} via MAX(id) fallback for ${table}`);
return fallback;
}
}
console.warn("[DB] insert: failed to read generated rowid, returning 0");
return 0;
}
query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] {
const shortSql = sql.trim().replace(/\s+/g, " ").slice(0, 80);
const stmt = this.inner.prepare(sql);
stmt.bind(params);
const rows: T[] = [];
while (stmt.step()) rows.push(stmt.getAsObject() as T);
stmt.free();
console.debug(`[DB] query: ${shortSql} → ${rows.length} rows`, params.length ? params : "");
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();
console.debug(`[DB] persist: exporting ${data.length} bytes`);
// Avoid spread-arg stack overflow on large Uint8Arrays by chunking
let b64 = "";
const CHUNK = 8192;
for (let i = 0; i < data.length; i += CHUNK) {
b64 += String.fromCharCode(...data.subarray(i, i + CHUNK));
}
const encoded = btoa(b64);
localStorage.setItem("forgelm_db_v2", encoded);
console.debug(`[DB] persist: saved ${encoded.length} chars to localStorage`);
} catch (e) {
console.error("[DB] persist failed:", e);
}
}
}
function normalizeInsertId(value: unknown): number | null {
if (typeof value === "number") {
return Number.isSafeInteger(value) && value > 0 ? value : null;
}
if (typeof value === "bigint") {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
if (typeof value === "string" && value.trim()) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
return null;
}
function extractInsertTable(sql: string): string | null {
const match = sql.match(/insert\s+into\s+([A-Za-z_][A-Za-z0-9_]*)/i);
return match?.[1] ?? null;
}
function hasColumn(db: DB, table: string, column: string): boolean {
const rows = db.query<{ name?: string }>(`PRAGMA table_info(${table})`);
return rows.some((row) => row.name === column);
}
function ensureColumn(db: DB, table: string, column: string, definition: string) {
if (!hasColumn(db, table, column)) {
db.run(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
}
}
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) {
console.log(`[DB] initDB: loading from localStorage (${saved.length} chars)`);
const buf = Uint8Array.from(atob(saved), (c) => c.charCodeAt(0));
console.log(`[DB] initDB: decoded to ${buf.length} bytes`);
inner = new SQL.Database(buf);
console.log("[DB] initDB: database loaded from storage");
} else {
console.log("[DB] initDB: no saved database found, creating fresh");
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)`);
ensureColumn(db, "messages", "reasoning_content", "reasoning_content TEXT");
ensureColumn(db, "messages", "prompt_tokens", "prompt_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "completion_tokens", "completion_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "total_tokens", "total_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "tokens_per_second", "tokens_per_second REAL");
const chatCount = db.get<{ c: number }>("SELECT COUNT(*) as c FROM chats");
const msgCount = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages");
console.log(`[DB] initDB: ready — ${chatCount?.c ?? 0} chats, ${msgCount?.c ?? 0} messages`);
return db;
}
+20
View File
@@ -0,0 +1,20 @@
/* @vitest-environment jsdom */
import { beforeEach, describe, expect, it } from "vitest";
import { ensureHljs, renderMarkdown } from "./markdown";
describe("markdown rendering", () => {
beforeEach(async () => {
await ensureHljs();
});
it("renders code blocks with bundled syntax highlighting and copy controls", () => {
const html = renderMarkdown("```typescript\nconst value = 1;\n```");
expect(html).toContain("code-block");
expect(html).toContain("code-header");
expect(html).toContain("copy-code-btn");
expect(html).toContain("language-typescript");
expect(html).toContain("hljs");
});
});
+71
View File
@@ -0,0 +1,71 @@
import { marked, Renderer } from "marked";
import DOMPurify from "dompurify";
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import css from "highlight.js/lib/languages/css";
import go from "highlight.js/lib/languages/go";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import python from "highlight.js/lib/languages/python";
import rust from "highlight.js/lib/languages/rust";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
let hlReady = false;
export async function ensureHljs() {
if (hlReady) return;
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("rust", rust);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("css", css);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("go", go);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("c", c);
hlReady = true;
}
const renderer = new Renderer();
renderer.code = function (code: string, lang?: string) {
void ensureHljs();
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
let highlighted = code;
try {
highlighted = language !== "plaintext"
? hljs.highlight(code, { language }).value
: 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,
});
}
+41
View File
@@ -0,0 +1,41 @@
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;
reasoning_content?: string | null;
prompt_tokens?: number | null;
completion_tokens?: number | null;
total_tokens?: number | null;
tokens_per_second?: number | null;
created_at: number;
}
export interface ChatTokenTotals {
prompt_tokens: number;
completion_tokens: number;
total_tokens: 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>
);