Initial ForgeLM import
This commit is contained in:
commit
a392b9dc60
4296 files changed
+1212675
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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 || "";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { DB, initDB } from "../lib/db";
|
||||
|
||||
export function useDB() {
|
||||
const [db, setDb] = useState<DB | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
initDB().then((instance) => {
|
||||
setDb(instance);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { db, ready };
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "&").replace(/'/g, "'");
|
||||
|
||||
return `<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span class="code-lang">${language}</span>
|
||||
<button class="copy-code-btn" data-code='${escaped}'>Copy</button>
|
||||
</div>
|
||||
<pre><code class="hljs language-${language}">${highlighted}</code></pre>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
marked.use({ renderer });
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
const html = marked.parse(text) as string;
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ["div", "span", "button", "pre", "code"],
|
||||
ADD_ATTR: ["class", "data-code"],
|
||||
FORCE_BODY: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
Reference in new issue
Block a user