feat: add runtime lifecycle health revalidation

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-27 03:12:59 -03:00
1 parent f5a4a9ee97
commit b757990f8d
27 files changed
+4851 -277

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
import { invoke } from "@tauri-apps/api/core";
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
import type {
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, config });
}
}
export async function startRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("start_runtime_command");
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({
...snapshot,
status: "error",
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 });
}
}
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;
}
}
@@ -0,0 +1,355 @@
.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-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-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-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-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: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 span {
color: var(--text3);
}
.runtime-preset-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.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-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-footer-btn {
flex: 1;
padding: 12px 14px;
}
.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;
}
}
@@ -0,0 +1,480 @@
import { useEffect, useMemo, useState } from "react";
import {
pickRuntimeModelFiles,
pickRuntimeModelsDir,
queryRuntimeGpu,
scanRuntimeModels,
} from "../api";
import type {
RuntimeConfig,
RuntimeGpuInfo,
RuntimeModelFile,
RuntimeSnapshot,
} from "../types";
import "./RuntimeSettingsDrawer.css";
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
function pathName(path: string | null | undefined): string {
if (!path) return "";
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || path;
}
function toModelFile(path: string): RuntimeModelFile {
return { name: pathName(path) || path, path };
}
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;
});
}
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 || "";
}
interface Props {
open: boolean;
snapshot: RuntimeSnapshot;
busy: boolean;
onClose: () => void;
onSave: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
onStart: () => Promise<unknown>;
onStop: () => Promise<unknown>;
}
export default function RuntimeSettingsDrawer({
open,
snapshot,
busy,
onClose,
onSave,
onStart,
onStop,
}: Props) {
const [mode, setMode] = useState(snapshot.config.connection_mode);
const [startPolicy, setStartPolicy] = useState(snapshot.config.start_policy);
const [serverUrl, setServerUrl] = useState(snapshot.config.server_url);
const [apiKey, setApiKey] = useState(snapshot.config.api_key);
const [modelsDir, setModelsDir] = useState(snapshot.config.models_dir || "");
const [selectedModel, setSelectedModel] = useState(snapshot.config.model_path || "");
const [addr, setAddr] = useState(snapshot.config.addr);
const [gpuLayers, setGpuLayers] = useState(snapshot.config.gpu_layers);
const [ctxSize, setCtxSize] = useState(snapshot.config.ctx_size);
const [scannedModels, setScannedModels] = useState<RuntimeModelFile[]>([]);
const [extraModels, setExtraModels] = useState<RuntimeModelFile[]>([]);
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
const [gpuError, setGpuError] = useState("");
const [localError, setLocalError] = useState("");
const [scanning, setScanning] = useState(false);
useEffect(() => {
if (!open) return;
const extra = snapshot.config.extra_model_paths.map(toModelFile);
setMode(snapshot.config.connection_mode);
setStartPolicy(snapshot.config.start_policy);
setServerUrl(snapshot.config.server_url);
setApiKey(snapshot.config.api_key);
setModelsDir(snapshot.config.models_dir || "");
setSelectedModel(snapshot.config.model_path || "");
setAddr(snapshot.config.addr);
setGpuLayers(snapshot.config.gpu_layers);
setCtxSize(snapshot.config.ctx_size);
setExtraModels(extra);
setScannedModels([]);
setLocalError("");
setGpuError("");
if (snapshot.config.models_dir?.trim()) {
setScanning(true);
scanRuntimeModels(snapshot.config.models_dir)
.then((models) => {
setScannedModels(models);
setSelectedModel((current) => chooseModel(current || snapshot.config.model_path || "", models, extra));
})
.catch((error) => setLocalError(String(error)))
.finally(() => setScanning(false));
}
queryRuntimeGpu()
.then((info) => {
setGpuInfo(info);
if (info && snapshot.config.gpu_layers === 0) {
setGpuLayers(info.recommended_layers);
}
})
.catch((error) => setGpuError(String(error)));
}, [open, snapshot]);
const allModels = useMemo(() => {
const merged = dedupeModels([...scannedModels, ...extraModels]);
if (selectedModel && !merged.some((model) => model.path === selectedModel)) {
return [toModelFile(selectedModel), ...merged];
}
return merged;
}, [extraModels, scannedModels, selectedModel]);
const savedLocalModel = snapshot.config.model_path?.trim() || "";
const hasPendingModeChange = mode !== snapshot.config.connection_mode;
const maxLayers = gpuInfo?.recommended_layers ?? 200;
const startReason =
mode !== "local"
? "Save mode before starting the local runtime."
: !snapshot.binary_path
? "Build or bundle the local API binary before starting."
: !selectedModel.trim()
? "Choose a model before starting the local runtime."
: "";
if (!open) return null;
const buildPatch = (): Partial<RuntimeConfig> => ({
connection_mode: mode,
start_policy: startPolicy,
server_url: serverUrl.trim(),
api_key: apiKey.trim(),
models_dir: mode === "local" ? (modelsDir.trim() || null) : snapshot.config.models_dir,
model_path: mode === "local" ? (selectedModel.trim() || null) : snapshot.config.model_path,
addr: mode === "local" ? addr.trim() || snapshot.config.addr : snapshot.config.addr,
gpu_layers: mode === "local" ? Math.max(0, Math.min(gpuLayers, maxLayers)) : snapshot.config.gpu_layers,
ctx_size: mode === "local" ? Math.max(512, ctxSize || 4096) : snapshot.config.ctx_size,
extra_model_paths: mode === "local" ? extraModels.map((model) => model.path) : snapshot.config.extra_model_paths,
});
const handleSave = async () => {
setLocalError("");
try {
await onSave(buildPatch());
} 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));
}
};
const handleRescan = async (dir = modelsDir) => {
if (!dir.trim()) return;
setScanning(true);
setLocalError("");
try {
const models = await scanRuntimeModels(dir.trim());
setScannedModels(models);
setSelectedModel((current) => chooseModel(current, models, extraModels));
} catch (error) {
setLocalError(String(error));
setScannedModels([]);
} finally {
setScanning(false);
}
};
const handlePickDir = async () => {
setLocalError("");
try {
const result = await pickRuntimeModelsDir();
if (!result) return;
setModelsDir(result.dir);
setScannedModels(result.models);
setSelectedModel((current) => chooseModel(current, result.models, extraModels));
} catch (error) {
setLocalError(String(error));
}
};
const handlePickFiles = async () => {
setLocalError("");
try {
const picked = await pickRuntimeModelFiles();
if (picked.length === 0) return;
setExtraModels((current) => {
const next = dedupeModels([...current, ...picked]);
if (!selectedModel) {
setSelectedModel(next[0]?.path || "");
}
return next;
});
} catch (error) {
setLocalError(String(error));
}
};
const handleRemoveExtra = (path: string) => {
setExtraModels((current) => {
const next = current.filter((model) => model.path !== path);
setSelectedModel((selected) => (selected === path ? chooseModel("", scannedModels, next) : selected));
return next;
});
};
return (
<div className="runtime-drawer-shell" role="dialog" aria-modal="true" aria-label="Runtime settings">
<button className="runtime-drawer-backdrop" onClick={onClose} aria-label="Close runtime settings" />
<aside className="runtime-drawer">
<div className="runtime-drawer-header">
<div>
<div className="runtime-drawer-kicker">Runtime workspace</div>
<h2>One control plane for startup, models, and API routing</h2>
<p className="runtime-drawer-copy">
Save the config once, then start the managed API from the same surface.
</p>
</div>
<button className="runtime-drawer-close" onClick={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">{mode === "remote" ? (serverUrl.trim() || "Not configured") : `http://${addr.trim() || snapshot.config.addr}`}</div>
</div>
<div>
<div className="runtime-overview-label">Saved model</div>
<div className="runtime-overview-value">{savedLocalModel ? pathName(savedLocalModel) : "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${mode === "local" ? " active" : ""}`} onClick={() => setMode("local")} type="button">
Local API
</button>
<button className={`runtime-segmented-btn${mode === "remote" ? " active" : ""}`} onClick={() => setMode("remote")} type="button">
Remote API
</button>
</div>
</section>
{mode === "remote" ? (
<section className="runtime-drawer-section runtime-section-card">
<label className="runtime-field-label" htmlFor="runtime-remote-url">Remote server URL</label>
<input
id="runtime-remote-url"
className="runtime-field-input"
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
placeholder="https://api.example.com"
type="text"
/>
<label className="runtime-field-label" htmlFor="runtime-api-key">API key</label>
<input
id="runtime-api-key"
className="runtime-field-input"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="sk-..."
type="password"
/>
</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://{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">
<div>
<div className="runtime-field-label">Models folder</div>
<div className="runtime-static-value runtime-static-value-path">{modelsDir || "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={!modelsDir.trim() || scanning}>
{scanning ? "Scanning..." : "Rescan"}
</button>
<button className="runtime-inline-btn" onClick={handlePickFiles} type="button">Add files</button>
</div>
</div>
<label className="runtime-field-label" htmlFor="runtime-model-select">Selected model</label>
<select
id="runtime-model-select"
className="runtime-field-input runtime-field-select"
value={selectedModel}
onChange={(event) => setSelectedModel(event.target.value)}
>
<option value="">Choose a model</option>
{allModels.map((model) => (
<option key={model.path} value={model.path}>{model.name}</option>
))}
</select>
{extraModels.length > 0 && (
<div className="runtime-chip-list">
{extraModels.map((model) => (
<button
key={model.path}
className="runtime-chip"
onClick={() => handleRemoveExtra(model.path)}
type="button"
title={`Remove ${model.name}`}
>
{model.name}
<span>×</span>
</button>
))}
</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"
value={addr}
onChange={(event) => setAddr(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"
min={512}
step={512}
value={ctxSize}
onChange={(event) => setCtxSize(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={() => setGpuLayers(gpuInfo.recommended_layers)} type="button">
Use recommended
</button>
)}
</div>
<input
className="runtime-field-input"
min={0}
max={maxLayers}
value={gpuLayers}
onChange={(event) => setGpuLayers(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${ctxSize === preset ? " active" : ""}`}
onClick={() => setCtxSize(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={startPolicy === "on_app_launch"}
onChange={(event) => setStartPolicy(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}</div>
</section>
)}
{!snapshot.process_running && mode === "local" && startReason && (
<section className="runtime-drawer-section">
<div className="runtime-warning-banner">{startReason}</div>
</section>
)}
</div>
<div className="runtime-drawer-footer">
<button className="runtime-footer-btn runtime-footer-btn-secondary" onClick={() => { void handleSave(); }} type="button" disabled={busy}>
{busy ? "Saving..." : "Save settings"}
</button>
{mode === "local" && (
<button
className="runtime-footer-btn runtime-footer-btn-primary"
onClick={() => { void handleSaveAndStart(); }}
type="button"
disabled={busy || hasPendingModeChange || (!snapshot.process_running && Boolean(startReason))}
>
{hasPendingModeChange
? "Save mode before applying"
: snapshot.process_running
? "Stop local API"
: "Save and start local API"}
</button>
)}
</div>
</aside>
</div>
);
}
@@ -0,0 +1,159 @@
.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-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,72 @@
import { getRuntimeStartGuard, getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime-utils";
import type { 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;
onOpenSettings: () => void;
onStart: () => void;
onStop: () => void;
}
export default function RuntimeStatusCard({ snapshot, loading, busy, 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 primaryLabel = needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop";
const primaryDisabled = busy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
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>}
{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">
Manage
</button>
{snapshot.config.connection_mode === "local" && (
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
{busy ? "Working..." : primaryLabel}
</button>
)}
</div>
</section>
);
}
@@ -0,0 +1,425 @@
/* @vitest-environment jsdom */
import { useState } from "react";
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 {
bootstrapRuntime,
getRuntimeSnapshot,
pickRuntimeModelsDir,
saveRuntimeConfig,
startRuntime,
stopRuntime,
} from "../api";
import RuntimeSettingsDrawer from "./RuntimeSettingsDrawer";
import RuntimeStatusCard from "./RuntimeStatusCard";
import { useRuntimeManager } from "../useRuntimeManager";
import { createDefaultRuntimeSnapshot } from "../runtime-utils";
import type { RuntimeConfig, RuntimeSnapshot } from "../types";
type SnapshotOverrides = Partial<Omit<RuntimeSnapshot, "config">> & {
config?: Partial<RuntimeSnapshot["config"]>;
};
vi.mock("../api", () => ({
bootstrapRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()),
getRuntimeSnapshot: vi.fn(async () => createDefaultRuntimeSnapshot()),
pickRuntimeModelFiles: vi.fn(async () => []),
pickRuntimeModelsDir: vi.fn(async () => null),
queryRuntimeGpu: vi.fn(async () => null),
saveRuntimeConfig: vi.fn(async (config) => ({ ...createDefaultRuntimeSnapshot(), config })),
scanRuntimeModels: vi.fn(async () => []),
startRuntime: vi.fn(async () => ({ ...createDefaultRuntimeSnapshot(), status: "ready", ready: true, process_running: true })),
stopRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()),
}));
function makeSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
return {
...createDefaultRuntimeSnapshot(),
...overrides,
config: {
...createDefaultRuntimeSnapshot().config,
...overrides.config,
},
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function RuntimeManagerHarness({ startOpen = false }: { startOpen?: boolean }) {
const runtime = useRuntimeManager();
const [drawerOpen, setDrawerOpen] = useState(startOpen);
return (
<>
<RuntimeStatusCard
snapshot={runtime.snapshot}
loading={runtime.loading}
busy={runtime.busy}
onOpenSettings={() => setDrawerOpen(true)}
onStart={() => {
void runtime.startLocal();
}}
onStop={() => {
void runtime.stopLocal();
}}
/>
<RuntimeSettingsDrawer
open={drawerOpen}
snapshot={runtime.snapshot}
busy={runtime.busy}
onClose={() => setDrawerOpen(false)}
onSave={runtime.saveConfig}
onStart={runtime.startLocal}
onStop={runtime.stopLocal}
/>
</>
);
}
describe("Runtime app surface", () => {
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();
vi.clearAllMocks();
vi.useRealTimers();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("routes incomplete local runtime setup to the settings drawer instead of trying to start", async () => {
const onOpenSettings = vi.fn();
const onStart = vi.fn();
await act(async () => {
root.render(
<RuntimeStatusCard
snapshot={makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: { model_path: null, connection_mode: "local" },
})}
loading={false}
busy={false}
onOpenSettings={onOpenSettings}
onStart={onStart}
onStop={vi.fn()}
/>,
);
});
const button = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Set up"));
expect(button).toBeTruthy();
await act(async () => {
button?.click();
});
expect(onOpenSettings).toHaveBeenCalledTimes(1);
expect(onStart).not.toHaveBeenCalled();
});
it("saves local runtime settings before starting from the unified drawer", async () => {
const onSave = vi.fn(async (patch) => makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
...patch,
connection_mode: "local",
model_path: "D:/models/demo.gguf",
},
}));
const onStart = vi.fn(async () => makeSnapshot({ status: "starting" }));
await act(async () => {
root.render(
<RuntimeSettingsDrawer
open
snapshot={makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: "D:/models/demo.gguf",
models_dir: "D:/models",
},
})}
busy={false}
onClose={vi.fn()}
onSave={onSave}
onStart={onStart}
onStop={vi.fn()}
/>,
);
});
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API"));
expect(action).toBeTruthy();
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false);
await act(async () => {
action?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave.mock.calls[0]?.[0]).toMatchObject({
connection_mode: "local",
model_path: "D:/models/demo.gguf",
models_dir: "D:/models",
});
expect(onStart).toHaveBeenCalledTimes(1);
});
it("stops the local runtime from the same drawer action when the process is already running", async () => {
const onSave = vi.fn();
const onStop = vi.fn(async () => makeSnapshot({ status: "stopping", process_running: false }));
await act(async () => {
root.render(
<RuntimeSettingsDrawer
open
snapshot={makeSnapshot({
status: "ready",
process_running: true,
ready: true,
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: "D:/models/demo.gguf",
},
})}
busy={false}
onClose={vi.fn()}
onSave={onSave}
onStart={vi.fn()}
onStop={onStop}
/>,
);
});
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Stop local API"));
expect(action).toBeTruthy();
await act(async () => {
action?.click();
await Promise.resolve();
});
expect(onStop).toHaveBeenCalledTimes(1);
expect(onSave).not.toHaveBeenCalled();
});
it("keeps the local primary CTA visible while switching from remote to local mode", async () => {
await act(async () => {
root.render(
<RuntimeSettingsDrawer
open
snapshot={makeSnapshot({
config: {
connection_mode: "remote",
server_url: "https://api.example.com",
api_key: "sk-demo",
},
})}
busy={false}
onClose={vi.fn()}
onSave={vi.fn(async () => makeSnapshot())}
onStart={vi.fn(async () => makeSnapshot({ status: "starting" }))}
onStop={vi.fn(async () => makeSnapshot({ status: "stopped" }))}
/>,
);
});
const localTab = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Local API"));
expect(localTab).toBeTruthy();
await act(async () => {
localTab?.click();
});
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save mode before applying"));
expect(action).toBeTruthy();
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(true);
});
it("covers the saved local startup lifecycle from stopped to starting to ready", async () => {
const initialSnapshot = makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: "D:/models/demo.gguf",
start_policy: "on_app_launch",
},
});
const startDeferred = createDeferred<RuntimeSnapshot>();
vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot);
vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot);
vi.mocked(startRuntime).mockReturnValue(startDeferred.promise);
await act(async () => {
root.render(<RuntimeManagerHarness />);
await Promise.resolve();
});
expect(container.textContent).toContain("Local API offline");
expect(container.textContent).toContain("demo.gguf");
const startButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Start"));
expect(startButton).toBeTruthy();
await act(async () => {
startButton?.click();
});
expect(container.textContent).toContain("Starting local API");
expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1);
await act(async () => {
startDeferred.resolve(makeSnapshot({
...initialSnapshot,
status: "ready",
process_running: true,
ready: true,
}));
await startDeferred.promise;
});
expect(container.textContent).toContain("Ready");
expect(container.textContent).toContain("Stop");
});
it("supports local onboarding from browsing a models folder through save and start", async () => {
const initialSnapshot = makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: null,
models_dir: null,
},
});
vi.mocked(pickRuntimeModelsDir).mockResolvedValue({
dir: "D:/models",
models: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
});
vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot);
vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot);
vi.mocked(saveRuntimeConfig).mockImplementation(async (config: RuntimeConfig) => makeSnapshot({
...initialSnapshot,
config,
}));
vi.mocked(startRuntime).mockResolvedValue(makeSnapshot({
...initialSnapshot,
status: "ready",
process_running: true,
ready: true,
config: {
...initialSnapshot.config,
models_dir: "D:/models",
model_path: "D:/models/demo.gguf",
},
}));
await act(async () => {
root.render(<RuntimeManagerHarness startOpen />);
await Promise.resolve();
});
const browseButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Browse"));
expect(browseButton).toBeTruthy();
await act(async () => {
browseButton?.click();
await Promise.resolve();
});
const select = container.querySelector("#runtime-model-select") as HTMLSelectElement | null;
expect(select?.value).toBe("D:/models/demo.gguf");
expect(container.textContent).toContain("D:/models");
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API"));
expect(action).toBeTruthy();
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false);
await act(async () => {
action?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(vi.mocked(saveRuntimeConfig)).toHaveBeenCalledTimes(1);
expect(vi.mocked(saveRuntimeConfig).mock.calls[0]?.[0]).toMatchObject({
connection_mode: "local",
models_dir: "D:/models",
model_path: "D:/models/demo.gguf",
});
expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Local API ready");
expect(container.textContent).toContain("Stop local API");
});
it("surfaces degraded local runtime state after periodic health revalidation fails", async () => {
vi.useFakeTimers();
const readySnapshot = makeSnapshot({
status: "ready",
ready: true,
process_running: true,
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: "D:/models/demo.gguf",
},
});
const degradedSnapshot = makeSnapshot({
...readySnapshot,
status: "degraded",
ready: false,
last_error: {
code: "health_check_failed",
message: "Local API process is still running, but /health is no longer ready.",
},
});
vi.mocked(bootstrapRuntime).mockResolvedValue(readySnapshot);
vi.mocked(getRuntimeSnapshot).mockResolvedValue(degradedSnapshot);
await act(async () => {
root.render(<RuntimeManagerHarness />);
await Promise.resolve();
});
expect(container.textContent).toContain("Ready");
expect(container.textContent).toContain("Stop");
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
expect(vi.mocked(getRuntimeSnapshot)).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Attention needed");
expect(container.textContent).toContain("Local API process is still running, but /health is no longer ready.");
expect(container.textContent).toContain("Stop");
});
});
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import { getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
import type { RuntimeSnapshot } from "./types";
function createSnapshot(overrides: Partial<RuntimeSnapshot> = {}): RuntimeSnapshot {
return {
status: "stopped",
ready: false,
process_running: false,
resolved_url: "http://127.0.0.1:8080",
binary_path: null,
last_error: null,
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: [],
},
...overrides,
};
}
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,119 @@
import type { RuntimeConnection, RuntimeSnapshot } from "./types";
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
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 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,
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: [],
},
};
}
+54
View File
@@ -0,0 +1,54 @@
export type ConnectionMode = "local" | "remote";
export type StartPolicy = "manual" | "on_app_launch";
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
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[];
}
export interface RuntimeErrorInfo {
code: string;
message: string;
}
export interface RuntimeSnapshot {
status: RuntimeStatus;
ready: boolean;
process_running: boolean;
resolved_url: string;
binary_path: string | null;
last_error: RuntimeErrorInfo | null;
config: RuntimeConfig;
}
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;
}
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, startRuntime, stopRuntime } from "./api";
import { createDefaultRuntimeSnapshot, getRuntimeConnection } from "./runtime-utils";
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
export function useRuntimeManager() {
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(createDefaultRuntimeSnapshot());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
const next = await getRuntimeSnapshot();
setSnapshot(next);
return next;
}, []);
useEffect(() => {
let active = true;
bootstrapRuntime()
.then((next) => {
if (active) {
setSnapshot(next);
setLoading(false);
}
})
.catch(() => {
if (active) {
setLoading(false);
}
});
const id = window.setInterval(() => {
refresh().catch(() => undefined);
}, 3000);
return () => {
active = false;
window.clearInterval(id);
};
}, [refresh]);
const saveConfig = useCallback(async (patch: Partial<RuntimeConfig>) => {
setBusy(true);
try {
const next = await saveRuntimeConfig({ ...snapshot.config, ...patch });
setSnapshot(next);
return next;
} finally {
setBusy(false);
}
}, [snapshot.config]);
const startLocal = useCallback(async () => {
setBusy(true);
setSnapshot((current) => ({
...current,
status: "starting",
process_running: true,
ready: false,
last_error: null,
}));
try {
const next = await startRuntime();
setSnapshot(next);
return next;
} finally {
setBusy(false);
}
}, []);
const stopLocal = useCallback(async () => {
setBusy(true);
setSnapshot((current) => ({
...current,
status: "stopping",
ready: false,
last_error: null,
}));
try {
const next = await stopRuntime();
setSnapshot(next);
return next;
} finally {
setBusy(false);
}
}, []);
const connection = useMemo(() => getRuntimeConnection(snapshot), [snapshot]);
return {
snapshot,
connection,
loading,
busy,
refresh,
saveConfig,
startLocal,
stopLocal,
};
}