feat: add SettingsPage with HuggingFace model search and fix flexbox layout
- Add SettingsPage component with macOS/Linear-style settings UI (sp-* classes) - Fix HuggingFace results list squeeze: sp-card-row--full now has align-items: stretch overriding the inherited flex-start, ensuring children fill 100% horizontal width - Fix sp-hf-file row layout: width 100%, grid columns for name/quant/size/download - Fix sp-hf-name truncation: explicit overflow/ellipsis/white-space in CSS - Add white-space: nowrap to sp-hf-size and sp-hf-quant-badge to protect secondary columns - Add runtime-deps crate, Tailwind/PostCSS config, runtime-defaults, MessageBubble test - Update Cargo workspace, inference-server, tauri runtime types and process handling
This commit is contained in:
1 parent
9da25c3688
commit
01b629cb4a
41 files changed
+6864
-822
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
resolveDraftModelSelection,
|
||||
toModelFile,
|
||||
} from "../runtime-settings-form";
|
||||
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime-defaults";
|
||||
import {
|
||||
createRuntimeDraftFieldState,
|
||||
type ModelLibraryState,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
} 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" },
|
||||
@@ -799,10 +799,10 @@ export default function RuntimeSettingsDrawer({
|
||||
<input
|
||||
id="runtime-context"
|
||||
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
|
||||
min={512}
|
||||
min={MIN_CTX_SIZE}
|
||||
step={512}
|
||||
value={draftConfig.ctx_size}
|
||||
onChange={(event) => updateDraft({ ctx_size: Math.max(512, parseInt(event.target.value, 10) || 4096) })}
|
||||
onChange={(event) => updateDraft({ ctx_size: Math.max(MIN_CTX_SIZE, parseInt(event.target.value, 10) || DEFAULT_CTX_SIZE) })}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-card-header-left {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.runtime-card-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
@@ -42,6 +47,25 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
/* Pulsing green dot when runtime is running */
|
||||
.runtime-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #30d158;
|
||||
flex-shrink: 0;
|
||||
animation: runtime-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes runtime-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(48, 209, 88, 0.5); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(48, 209, 88, 0); }
|
||||
}
|
||||
|
||||
.runtime-card-mode {
|
||||
@@ -92,6 +116,13 @@
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.runtime-card-meta-model {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runtime-card-note {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
@@ -177,12 +208,63 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary {
|
||||
/* Start — green */
|
||||
.runtime-card-btn-start {
|
||||
color: #fff;
|
||||
border-color: rgba(48, 209, 88, 0.5);
|
||||
background: linear-gradient(135deg, #1ea84d, #30d158);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runtime-card-btn-start:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #25c05a, #3fe068);
|
||||
}
|
||||
|
||||
/* Stop — red */
|
||||
.runtime-card-btn-stop {
|
||||
color: #fff;
|
||||
border-color: rgba(240, 64, 64, 0.5);
|
||||
background: linear-gradient(135deg, #c0302e, #f04040);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runtime-card-btn-stop:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #d03838, #f55555);
|
||||
}
|
||||
|
||||
/* Setup — blue (same as primary) */
|
||||
.runtime-card-btn-setup {
|
||||
color: #fff;
|
||||
border-color: rgba(72, 166, 255, 0.6);
|
||||
background: linear-gradient(135deg, #2b88e6, #46c2ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary:hover:not(:disabled) {
|
||||
.runtime-card-btn-setup:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #3193f5, #55cbff);
|
||||
}
|
||||
|
||||
/* Spinner for in-progress lifecycle */
|
||||
.runtime-btn-spinner {
|
||||
display: inline-block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: runtime-spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes runtime-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -62,26 +62,35 @@ export default function RuntimeStatusCard({
|
||||
? "runtime-card-result-warning"
|
||||
: "runtime-card-result-success";
|
||||
|
||||
const isRunning = isLocal && snapshot.process_running;
|
||||
const isStopping = isLocal && snapshot.status === "stopping";
|
||||
const primaryVariant = needsSetup ? "setup" : isRunning ? "stop" : "start";
|
||||
|
||||
return (
|
||||
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
|
||||
<div className="runtime-card-header">
|
||||
<div>
|
||||
<div className="runtime-card-header-left">
|
||||
<div className="runtime-card-kicker">Runtime Manager</div>
|
||||
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
|
||||
<div className="runtime-card-title">
|
||||
{isRunning && !isStopping && <span className="runtime-status-dot" aria-hidden="true" />}
|
||||
{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">
|
||||
<p className="runtime-card-copy" title={snapshot.config.connection_mode === "local" ? snapshot.resolved_url ?? "" : snapshot.config.server_url ?? ""}>
|
||||
{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 className="runtime-card-meta-model" title={snapshot.config.model_path ?? ""}>
|
||||
{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}
|
||||
</span>
|
||||
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +113,13 @@ export default function RuntimeStatusCard({
|
||||
Manage
|
||||
</button>
|
||||
{snapshot.config.connection_mode === "local" && (
|
||||
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
|
||||
<button
|
||||
className={`runtime-card-btn runtime-card-btn-${primaryVariant}`}
|
||||
onClick={primaryAction}
|
||||
type="button"
|
||||
disabled={primaryDisabled}
|
||||
>
|
||||
{lifecycleBusy ? <span className="runtime-btn-spinner" aria-hidden="true" /> : null}
|
||||
{primaryLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const MIN_CTX_SIZE = 512;
|
||||
export const DEFAULT_CTX_SIZE = 4096;
|
||||
export const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
toModelFile,
|
||||
type RuntimeSettingsFormState,
|
||||
} from "./runtime-settings-form";
|
||||
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
|
||||
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
|
||||
import {
|
||||
createRuntimeDraftState,
|
||||
@@ -185,6 +186,17 @@ describe("runtime settings form helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the shared desktop default context size when the draft value is empty", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
const patch = createRuntimeConfigPatch(
|
||||
snapshot.config,
|
||||
createFormState({ ctxSize: 0 }),
|
||||
32,
|
||||
);
|
||||
|
||||
expect(patch.ctx_size).toBe(DEFAULT_CTX_SIZE);
|
||||
});
|
||||
|
||||
it("keeps saved local values untouched when editing remote mode", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
const patch = createRuntimeConfigPatch(
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RuntimeModelFile,
|
||||
StartPolicy,
|
||||
} from "./types";
|
||||
import { DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "./runtime-defaults";
|
||||
|
||||
function normalizeUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
@@ -126,7 +127,7 @@ export function createRuntimeConfigPatch(
|
||||
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,
|
||||
ctx_size: form.mode === "local" ? Math.max(MIN_CTX_SIZE, form.ctxSize || DEFAULT_CTX_SIZE) : snapshotConfig.ctx_size,
|
||||
selected_model_id: form.mode === "local" ? form.selectedModelId ?? null : snapshotConfig.selected_model_id ?? null,
|
||||
extra_model_paths:
|
||||
form.mode === "local"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRuntimeLifecycleResult, getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
|
||||
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
|
||||
import {
|
||||
createDefaultRuntimeSnapshot,
|
||||
createRuntimeLifecycleResult,
|
||||
getRuntimeConnection,
|
||||
getRuntimeStartGuard,
|
||||
getRuntimeStateLabel,
|
||||
getRuntimeStatusTone,
|
||||
} from "./runtime-utils";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
|
||||
|
||||
type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & {
|
||||
@@ -42,6 +50,10 @@ function createSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
|
||||
}
|
||||
|
||||
describe("getRuntimeConnection", () => {
|
||||
it("creates local snapshots with the shared desktop default context window", () => {
|
||||
expect(createDefaultRuntimeSnapshot().config.ctx_size).toBe(DEFAULT_CTX_SIZE);
|
||||
});
|
||||
|
||||
it("prefers the resolved local runtime url in local mode", () => {
|
||||
const connection = getRuntimeConnection(createSnapshot({
|
||||
resolved_url: "http://127.0.0.1:9000",
|
||||
@@ -90,6 +102,10 @@ describe("getRuntimeStateLabel", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
|
||||
});
|
||||
|
||||
it("reports local processes that lost readiness as not ready", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("Not ready");
|
||||
});
|
||||
|
||||
it("reports local background start progress", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
|
||||
});
|
||||
@@ -113,6 +129,12 @@ describe("getRuntimeStateLabel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStatusTone", () => {
|
||||
it("marks local not-ready snapshots as danger", () => {
|
||||
expect(getRuntimeStatusTone(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("danger");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStartGuard", () => {
|
||||
it("requires a saved model before starting the local runtime", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
RuntimeWorkspaceSummary,
|
||||
} from "./types";
|
||||
import { scanRuntimeModels } from "./api";
|
||||
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
|
||||
|
||||
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
|
||||
|
||||
@@ -179,6 +180,10 @@ export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
|
||||
return "Remote API";
|
||||
}
|
||||
|
||||
if (snapshot.status === "ready" && !snapshot.ready) {
|
||||
return snapshot.process_running ? "Not ready" : "Local API offline";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "Ready";
|
||||
@@ -200,6 +205,10 @@ export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "su
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
if (snapshot.status === "ready" && !snapshot.ready) {
|
||||
return snapshot.process_running ? "danger" : "neutral";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "success";
|
||||
@@ -299,7 +308,7 @@ export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
ctx_size: DEFAULT_CTX_SIZE,
|
||||
extra_model_paths: [],
|
||||
selected_model_id: null,
|
||||
remote_presets: [],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,922 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
pickRuntimeModelFiles,
|
||||
pickRuntimeModelsDir,
|
||||
queryRuntimeGpu,
|
||||
searchHuggingFaceModels,
|
||||
} from "../runtime/api";
|
||||
import {
|
||||
dedupeModels,
|
||||
pathName,
|
||||
toModelFile,
|
||||
} from "../runtime/runtime-settings-form";
|
||||
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime/runtime-defaults";
|
||||
import { getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime/runtime-utils";
|
||||
import type {
|
||||
HuggingFaceModelResult,
|
||||
RuntimeConfig,
|
||||
RuntimeGpuInfo,
|
||||
RuntimeModelFile,
|
||||
RuntimeSnapshot,
|
||||
} from "../runtime/types";
|
||||
import { Box, Zap, ArrowLeftRight, Settings2, X, Trash2, Search, Info, Download } from "lucide-react";
|
||||
import "./SettingsPage.css";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Section = "model" | "runtime" | "connection" | "advanced";
|
||||
|
||||
interface SettingsRuntime {
|
||||
snapshot: RuntimeSnapshot;
|
||||
savedBaseline: RuntimeConfig;
|
||||
draftConfig: RuntimeConfig;
|
||||
draftDirty: boolean;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
lifecycleBusy: boolean;
|
||||
scannedModels: RuntimeModelFile[];
|
||||
isScanningModels: boolean;
|
||||
updateDraft: (patch: Partial<RuntimeConfig>) => void;
|
||||
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||||
startLocal: () => Promise<unknown>;
|
||||
stopLocal: () => Promise<unknown>;
|
||||
scanModels: (dir: string | null) => Promise<void>;
|
||||
resetDraft: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
runtime: SettingsRuntime;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ─── Nav definition ──────────────────────────────────────────────────────────
|
||||
|
||||
const NAV: { id: Section; label: string; icon: typeof Box }[] = [
|
||||
{ id: "model", label: "Model", icon: Box },
|
||||
{ id: "runtime", label: "Runtime", icon: Zap },
|
||||
{ id: "connection", label: "Connection", icon: ArrowLeftRight },
|
||||
{ id: "advanced", label: "Advanced", icon: Settings2 },
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (!bytes) return "";
|
||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GiB`;
|
||||
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MiB`;
|
||||
return `${(bytes / 1024).toFixed(0)} KiB`;
|
||||
}
|
||||
|
||||
function statusColor(snapshot: RuntimeSnapshot): string {
|
||||
const tone = getRuntimeStatusTone(snapshot);
|
||||
if (tone === "success") return "sp-dot--success";
|
||||
if (tone === "warning") return "sp-dot--warn";
|
||||
if (tone === "danger") return "sp-dot--danger";
|
||||
return "sp-dot--idle";
|
||||
}
|
||||
|
||||
function statusPillClass(snapshot: RuntimeSnapshot): string {
|
||||
const tone = getRuntimeStatusTone(snapshot);
|
||||
if (tone === "success") return "sp-status-pill--success";
|
||||
if (tone === "warning") return "sp-status-pill--warn";
|
||||
if (tone === "danger") return "sp-status-pill--danger";
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── SettingsPage ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage({ runtime, onClose }: Props) {
|
||||
const {
|
||||
snapshot, savedBaseline, draftConfig, draftDirty,
|
||||
loading, busy, lifecycleBusy,
|
||||
scannedModels, isScanningModels,
|
||||
updateDraft, saveConfig, startLocal, stopLocal, scanModels, resetDraft,
|
||||
} = runtime;
|
||||
|
||||
const [section, setSection] = useState<Section>("model");
|
||||
const [hfQuery, setHfQuery] = useState("");
|
||||
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
|
||||
const [hfLoading, setHfLoading] = useState(false);
|
||||
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const didAutoScan = useRef(false);
|
||||
const busyAll = busy || lifecycleBusy || saving;
|
||||
|
||||
// ── Side-effects ──────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
queryRuntimeGpu().then(setGpuInfo).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "model" || didAutoScan.current) return;
|
||||
const dir = draftConfig.models_dir?.trim();
|
||||
if (dir && scannedModels.length === 0 && !isScanningModels) {
|
||||
didAutoScan.current = true;
|
||||
void scanModels(dir);
|
||||
}
|
||||
}, [section, draftConfig.models_dir, scannedModels.length, isScanningModels, scanModels]);
|
||||
|
||||
// ── Derived data ──────────────────────────────────────────────────────────
|
||||
|
||||
const allModels = dedupeModels([
|
||||
...scannedModels,
|
||||
...draftConfig.extra_model_paths.map(toModelFile),
|
||||
]);
|
||||
|
||||
const isLocal = draftConfig.connection_mode === "local";
|
||||
const isRunning = snapshot.process_running;
|
||||
const isStopping = snapshot.status === "stopping";
|
||||
const runtimeStatusLabel = loading ? "Loading..." : getRuntimeStateLabel(snapshot);
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handlePickDir = useCallback(async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
const result = await pickRuntimeModelsDir();
|
||||
if (!result) return;
|
||||
updateDraft({ models_dir: result.dir });
|
||||
void scanModels(result.dir);
|
||||
} catch (e) {
|
||||
setLocalError(String(e));
|
||||
}
|
||||
}, [updateDraft, scanModels]);
|
||||
|
||||
const handlePickFiles = useCallback(async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
const picked = await pickRuntimeModelFiles();
|
||||
if (picked.length === 0) return;
|
||||
const next = dedupeModels([
|
||||
...draftConfig.extra_model_paths.map(toModelFile),
|
||||
...picked,
|
||||
]);
|
||||
updateDraft({
|
||||
extra_model_paths: next.map((m) => m.path),
|
||||
model_path: draftConfig.model_path || next[0]?.path || null,
|
||||
});
|
||||
} catch (e) {
|
||||
setLocalError(String(e));
|
||||
}
|
||||
}, [draftConfig, updateDraft]);
|
||||
|
||||
const handleSelectModel = useCallback((path: string) => {
|
||||
updateDraft({ model_path: path, selected_model_id: null });
|
||||
}, [updateDraft]);
|
||||
|
||||
const handleRemoveExtra = useCallback((path: string) => {
|
||||
const next = draftConfig.extra_model_paths.filter((p) => p !== path);
|
||||
updateDraft({
|
||||
extra_model_paths: next,
|
||||
model_path: draftConfig.model_path === path
|
||||
? (next[0] ?? scannedModels[0]?.path ?? null)
|
||||
: draftConfig.model_path,
|
||||
});
|
||||
}, [draftConfig, scannedModels, updateDraft]);
|
||||
|
||||
const handleHfSearch = useCallback(async () => {
|
||||
if (!hfQuery.trim()) return;
|
||||
setHfLoading(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
const results = await searchHuggingFaceModels(hfQuery, draftConfig.hugging_face_token || "");
|
||||
setHfResults(results);
|
||||
} catch (e) {
|
||||
setLocalError(String(e));
|
||||
} finally {
|
||||
setHfLoading(false);
|
||||
}
|
||||
}, [hfQuery, draftConfig.hugging_face_token]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
await saveConfig(draftConfig);
|
||||
} catch (e) {
|
||||
setLocalError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [saveConfig, draftConfig]);
|
||||
|
||||
const handleSaveAndStart = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
await saveConfig(draftConfig);
|
||||
await startLocal();
|
||||
} catch (e) {
|
||||
setLocalError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [saveConfig, draftConfig, startLocal]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
await stopLocal();
|
||||
} catch (e) {
|
||||
setLocalError(String(e));
|
||||
}
|
||||
}, [stopLocal]);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="sp-shell flex h-full overflow-hidden">
|
||||
|
||||
{/* ── Left navigation ──────────────────────────────────────────── */}
|
||||
<nav className="sp-nav">
|
||||
<div className="sp-nav-header">
|
||||
<div className="sp-nav-brand">ForgeLM</div>
|
||||
<h2 className="sp-nav-title">Settings</h2>
|
||||
</div>
|
||||
|
||||
<div className="sp-nav-items">
|
||||
{NAV.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSection(item.id)}
|
||||
className={`sp-nav-item${section === item.id ? " sp-nav-item--active" : ""}`}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.5} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Runtime status pill */}
|
||||
<div className="sp-nav-status">
|
||||
<span className={`sp-dot ${statusColor(snapshot)}`} />
|
||||
<span className="text-xs sp-nav-status-label">
|
||||
{runtimeStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="sp-nav-footer">
|
||||
<button type="button" onClick={onClose} className="sp-back-btn">
|
||||
← Back to Chat
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── Main area (right side) ─────────────────────────────────── */}
|
||||
<div className="settings-main-area">
|
||||
<div className="settings-scroll-container">
|
||||
<div className="settings-content">
|
||||
|
||||
{/* ═══════════════════════════════════ MODEL SECTION ═══════════════════════════ */}
|
||||
{section === "model" && (
|
||||
<>
|
||||
<header className="page-header sp-section-header">
|
||||
<div>
|
||||
<h2 className="sp-section-title">Model</h2>
|
||||
<p className="sp-section-desc">
|
||||
Select a local GGUF model file for inference.
|
||||
</p>
|
||||
</div>
|
||||
{isRunning && (
|
||||
<span className={`sp-status-pill ${statusPillClass(snapshot)}`}>
|
||||
<span className="sp-dot sp-dot--inline" />
|
||||
{isStopping ? "Stopping local API" : getRuntimeStateLabel(snapshot)}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="settings-cards">
|
||||
{localError && <div className="sp-error-inline">{localError}</div>}
|
||||
|
||||
{/* Active model */}
|
||||
<div className="sp-card">
|
||||
{snapshot.config.model_path ? (
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Active model</div>
|
||||
<div className="sp-card-row-desc">Currently loaded GGUF file</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<div className="sp-model-name">{pathName(snapshot.config.model_path)}</div>
|
||||
<div className="sp-model-path">{snapshot.config.model_path}</div>
|
||||
{draftDirty && draftConfig.model_path !== snapshot.config.model_path && (
|
||||
<div className="sp-pending mt-2">
|
||||
Draft → {pathName(draftConfig.model_path) || "None selected"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Active model</div>
|
||||
<div className="sp-card-row-desc">No model selected yet</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<p className="sp-empty">Use the sections below to load one.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scan folder */}
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Scan folder</div>
|
||||
<div className="sp-card-row-desc">Browse and scan a directory for .gguf files</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<div className="sp-folder-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn sp-btn--secondary"
|
||||
onClick={() => void handlePickDir()}
|
||||
disabled={busyAll}
|
||||
>
|
||||
Browse…
|
||||
</button>
|
||||
{draftConfig.models_dir && (
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn sp-btn--ghost"
|
||||
onClick={() => { void scanModels(draftConfig.models_dir!); }}
|
||||
disabled={busyAll || isScanningModels}
|
||||
>
|
||||
{isScanningModels ? "Scanning…" : "Rescan"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{draftConfig.models_dir && (
|
||||
<div className="sp-folder-path">{draftConfig.models_dir}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model list from scan */}
|
||||
{(isScanningModels || scannedModels.length > 0) && (
|
||||
<>
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row sp-card-row--full">
|
||||
{isScanningModels && scannedModels.length === 0 && (
|
||||
<div className="sp-scanning-row">Scanning for models…</div>
|
||||
)}
|
||||
<div className="sp-model-list">
|
||||
{scannedModels.map((model) => {
|
||||
const selected = draftConfig.model_path === model.path;
|
||||
return (
|
||||
<button
|
||||
key={model.path}
|
||||
type="button"
|
||||
className={`sp-model-row${selected ? " sp-model-row--selected" : ""}`}
|
||||
onClick={() => handleSelectModel(model.path)}
|
||||
disabled={busyAll}
|
||||
>
|
||||
<span className={`sp-radio${selected ? " sp-radio--on" : ""}`} />
|
||||
<span className="flex-1 truncate sp-model-row-name">{model.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Browse individual files */}
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Individual files</div>
|
||||
<div className="sp-card-row-desc">Add specific .gguf files without scanning a folder</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn sp-btn--secondary"
|
||||
onClick={() => void handlePickFiles()}
|
||||
disabled={busyAll}
|
||||
>
|
||||
Pick files…
|
||||
</button>
|
||||
{draftConfig.extra_model_paths.length > 0 && (
|
||||
<div className="sp-model-list mt-3">
|
||||
{draftConfig.extra_model_paths.map((p) => {
|
||||
const selected = draftConfig.model_path === p;
|
||||
return (
|
||||
<div
|
||||
key={p}
|
||||
className={`sp-model-row${selected ? " sp-model-row--selected" : ""}${busyAll ? " sp-model-row--disabled" : ""}`}
|
||||
role="row"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="sp-model-row-select"
|
||||
onClick={() => handleSelectModel(p)}
|
||||
disabled={busyAll}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className={`sp-radio${selected ? " sp-radio--on" : ""}`} />
|
||||
<span className="flex-1 truncate sp-model-row-name">{pathName(p)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sp-remove-btn"
|
||||
onClick={() => handleRemoveExtra(p)}
|
||||
disabled={busyAll}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace search */}
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row sp-card-row--full">
|
||||
<div className="sp-hf-search-header">
|
||||
<div>
|
||||
<div className="sp-card-row-title">Search HuggingFace</div>
|
||||
<div className="sp-card-row-desc">
|
||||
{!draftConfig.hugging_face_token && "Add HF token in Advanced for private models"}
|
||||
</div>
|
||||
</div>
|
||||
<span className="tooltip-container">
|
||||
<Info size={14} className="sp-hf-info-icon" />
|
||||
<div className="sp-tooltip-content">
|
||||
<div className="sp-hf-tooltip-title">O que é Quantização?</div>
|
||||
<p className="sp-hf-tooltip-body">
|
||||
É a compressão do modelo para que ele rode mais rápido e caiba na memória (RAM/VRAM) do seu PC.
|
||||
</p>
|
||||
<div className="sp-hf-tooltip-levels">
|
||||
<span><span className="sp-hf-dot sp-hf-dot--green" /> <strong>Máxima Qualidade (Pesado):</strong> Q8_0, Q6_K — Quase sem perda de inteligência.</span>
|
||||
<span><span className="sp-hf-dot sp-hf-dot--blue" /> <strong>Equilíbrio Ideal (Recomendado):</strong> Q4_K_M, Q5_K_M — Melhor custo-benefício entre velocidade e resposta.</span>
|
||||
<span><span className="sp-hf-dot sp-hf-dot--yellow" /> <strong>Alta Compressão (Leve):</strong> Q2_K, Q3_K_M — Roda em PCs fracos, mas o modelo fica mais "esquecido" ou impreciso.</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className="sp-hf-search-bar">
|
||||
<input
|
||||
className="sp-input sp-hf-input"
|
||||
placeholder="Search for GGUF models…"
|
||||
value={hfQuery}
|
||||
onChange={(e) => setHfQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleHfSearch(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn sp-btn--primary"
|
||||
onClick={() => void handleHfSearch()}
|
||||
disabled={hfLoading || !hfQuery.trim()}
|
||||
>
|
||||
<Search size={14} strokeWidth={2} />
|
||||
{hfLoading ? "…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hfResults.length > 0 && (
|
||||
<div className="sp-hf-results">
|
||||
{hfResults.map((result) => (
|
||||
<div key={result.repo_id} className="sp-hf-card">
|
||||
<div className="sp-hf-card-header">
|
||||
<div className="sp-hf-repo">{result.repo_id}</div>
|
||||
<div className="sp-hf-author">{result.author || "Unknown author"}</div>
|
||||
</div>
|
||||
{result.verified_files.length > 0 && (
|
||||
<div className="sp-hf-files">
|
||||
{result.verified_files.map((file) => {
|
||||
const fileName = file.path.replace(/\.gguf$/i, "");
|
||||
const fileSize = file.size ? `${(file.size / 1_073_741_824).toFixed(1)} GB` : "—";
|
||||
return (
|
||||
<div key={`${result.repo_id}:${file.path}`} className="sp-hf-file">
|
||||
<span className="sp-hf-name truncate" title={file.path}>{fileName}</span>
|
||||
<span className="sp-hf-size">{fileSize}</span>
|
||||
<span className="sp-hf-quant-badge">{file.quantization || "gguf"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="sp-hf-download-btn"
|
||||
title={`Download ${file.path}`}
|
||||
onClick={() => {
|
||||
// TODO: wire up actual download
|
||||
console.log("Download:", file.path);
|
||||
}}
|
||||
>
|
||||
<Download size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hfLoading && hfQuery && hfResults.length === 0 && (
|
||||
<p className="sp-hint mt-3">No results. Try a different query.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════ RUNTIME SECTION ════════════════════════ */}
|
||||
{section === "runtime" && (
|
||||
<>
|
||||
<header className="page-header sp-section-header">
|
||||
<div>
|
||||
<h2 className="sp-section-title">Runtime</h2>
|
||||
<p className="sp-section-desc">GPU acceleration, context window, and startup policy.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-cards">
|
||||
{localError && <div className="sp-error-inline">{localError}</div>}
|
||||
|
||||
<div className="sp-card">
|
||||
{/* GPU acceleration */}
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">GPU acceleration</div>
|
||||
<div className="sp-card-row-desc">
|
||||
{gpuInfo ? `${gpuInfo.name} · ${gpuInfo.vram_mib} MiB VRAM` : "No GPU detected — CPU only"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
{gpuInfo && (
|
||||
<span className="sp-status-pill sp-status-pill--success">GPU detected</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">GPU layers</div>
|
||||
<div className="sp-card-row-desc">Layers to offload to the GPU</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control sp-card-row-control--wide">
|
||||
<div className="sp-field-value">{draftConfig.gpu_layers}</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={gpuInfo ? Math.max(gpuInfo.recommended_layers, draftConfig.gpu_layers, 64) : 64}
|
||||
value={draftConfig.gpu_layers}
|
||||
onChange={(e) => updateDraft({ gpu_layers: Number(e.target.value) })}
|
||||
className="sp-slider w-full mt-2"
|
||||
disabled={busyAll}
|
||||
/>
|
||||
<div className="sp-slider-presets">
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn-xs"
|
||||
onClick={() => updateDraft({ gpu_layers: 0 })}
|
||||
>
|
||||
0 — CPU only
|
||||
</button>
|
||||
{gpuInfo && (
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn-xs"
|
||||
onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })}
|
||||
>
|
||||
{gpuInfo.recommended_layers} — Recommended
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context window */}
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Context window</div>
|
||||
<div className="sp-card-row-desc">Maximum tokens the model can process</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control sp-card-row-control--wide">
|
||||
<div className="sp-field-value">{draftConfig.ctx_size.toLocaleString()} tokens</div>
|
||||
<div className="sp-segmented">
|
||||
{CTX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => updateDraft({ ctx_size: preset })}
|
||||
className={`sp-segmented-btn${draftConfig.ctx_size === preset ? " sp-segmented-btn--active" : ""}`}
|
||||
disabled={busyAll}
|
||||
>
|
||||
{preset >= 1024 ? `${preset / 1024}K` : preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sp-ctx-custom">
|
||||
<input
|
||||
type="number"
|
||||
className="sp-input sp-input--sm"
|
||||
value={draftConfig.ctx_size}
|
||||
min={MIN_CTX_SIZE}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= MIN_CTX_SIZE) updateDraft({ ctx_size: v });
|
||||
}}
|
||||
disabled={busyAll}
|
||||
/>
|
||||
<span className="sp-hint">Min {MIN_CTX_SIZE.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Startup policy */}
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Startup policy</div>
|
||||
<div className="sp-card-row-desc">When to start the local API</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<div className="sp-toggles">
|
||||
{(["manual", "on_app_launch"] as const).map((policy) => {
|
||||
const active = draftConfig.start_policy === policy;
|
||||
return (
|
||||
<label key={policy} className={`sp-toggle${active ? " sp-toggle--active" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={() => updateDraft({ start_policy: policy })}
|
||||
disabled={busyAll}
|
||||
/>
|
||||
<span className="sp-toggle-track" />
|
||||
<span className="sp-toggle-label">
|
||||
{policy === "manual" ? "Manual" : "Auto-start"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="sp-toggle-desc">
|
||||
{draftConfig.start_policy === "manual"
|
||||
? "Start the local API from the runtime panel when needed."
|
||||
: "Automatically start the local API when ForgeLM opens."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════ CONNECTION SECTION ═════════════════════ */}
|
||||
{section === "connection" && (
|
||||
<>
|
||||
<header className="page-header sp-section-header">
|
||||
<div>
|
||||
<h2 className="sp-section-title">Connection</h2>
|
||||
<p className="sp-section-desc">
|
||||
Use the built-in local inference engine or connect to a remote OpenAI-compatible server.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-cards">
|
||||
{localError && <div className="sp-error-inline">{localError}</div>}
|
||||
|
||||
<div className="sp-card">
|
||||
{/* Mode */}
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Mode</div>
|
||||
<div className="sp-card-row-desc">
|
||||
{draftConfig.connection_mode === "local"
|
||||
? "Runs the inference server locally on your machine."
|
||||
: "Forwards all requests to an external OpenAI-compatible endpoint."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<div className="sp-segmented">
|
||||
{(["local", "remote"] as const).map((mode) => {
|
||||
const active = draftConfig.connection_mode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`sp-segmented-btn${active ? " sp-segmented-btn--active" : ""}`}
|
||||
onClick={() => updateDraft({ connection_mode: mode })}
|
||||
disabled={busyAll}
|
||||
>
|
||||
{mode === "local" ? "Local API" : "Remote API"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local settings */}
|
||||
{draftConfig.connection_mode === "local" && (
|
||||
<>
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Bind address</div>
|
||||
<div className="sp-card-row-desc">Host and port for the local API</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<input
|
||||
className="sp-input w-full"
|
||||
value={draftConfig.addr}
|
||||
onChange={(e) => updateDraft({ addr: e.target.value })}
|
||||
placeholder="127.0.0.1:8080"
|
||||
disabled={busyAll}
|
||||
/>
|
||||
<p className="sp-hint mt-2">
|
||||
Available at{" "}
|
||||
<code>http://{draftConfig.addr || "127.0.0.1:8080"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remote settings */}
|
||||
{draftConfig.connection_mode === "remote" && (
|
||||
<>
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Server URL</div>
|
||||
<div className="sp-card-row-desc">Base URL of the remote API</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<input
|
||||
className="sp-input w-full"
|
||||
value={draftConfig.server_url}
|
||||
onChange={(e) => updateDraft({ server_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
disabled={busyAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">API key</div>
|
||||
<div className="sp-card-row-desc">Authentication key for the remote server</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<input
|
||||
className="sp-input w-full"
|
||||
type="password"
|
||||
value={draftConfig.api_key}
|
||||
onChange={(e) => updateDraft({ api_key: e.target.value })}
|
||||
placeholder="sk-…"
|
||||
disabled={busyAll}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════ ADVANCED SECTION ═══════════════════════ */}
|
||||
{section === "advanced" && (
|
||||
<>
|
||||
<header className="page-header sp-section-header">
|
||||
<div>
|
||||
<h2 className="sp-section-title">Advanced</h2>
|
||||
<p className="sp-section-desc">HuggingFace access and diagnostic information.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-cards">
|
||||
{localError && <div className="sp-error-inline">{localError}</div>}
|
||||
|
||||
<div className="sp-card">
|
||||
{/* HF Token */}
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">HuggingFace token</div>
|
||||
<div className="sp-card-row-desc">Required for gated or private models</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
{draftConfig.hugging_face_token && (
|
||||
<span className="sp-status-pill sp-status-pill--success">Configured</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row">
|
||||
<div className="sp-card-row-label">
|
||||
<div className="sp-card-row-title">Token value</div>
|
||||
<div className="sp-card-row-desc">Generate at huggingface.co/settings/tokens</div>
|
||||
</div>
|
||||
<div className="sp-card-row-control">
|
||||
<input
|
||||
className="sp-input w-full"
|
||||
type="password"
|
||||
value={draftConfig.hugging_face_token || ""}
|
||||
onChange={(e) => updateDraft({ hugging_face_token: e.target.value })}
|
||||
placeholder="hf_…"
|
||||
disabled={busyAll}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics */}
|
||||
<div className="sp-card-divider" />
|
||||
<div className="sp-card-row sp-card-row--full">
|
||||
<div className="sp-card-row-title">Diagnostics</div>
|
||||
<dl className="sp-diag-list">
|
||||
<div className="sp-diag-row">
|
||||
<dt className="sp-diag-key">Runtime status</dt>
|
||||
<dd className="sp-diag-val">{snapshot.status}</dd>
|
||||
</div>
|
||||
<div className="sp-diag-row">
|
||||
<dt className="sp-diag-key">Binary path</dt>
|
||||
<dd className="sp-diag-val sp-mono">
|
||||
{snapshot.binary_path || "Not found"}
|
||||
</dd>
|
||||
</div>
|
||||
{snapshot.resolved_url && (
|
||||
<div className="sp-diag-row">
|
||||
<dt className="sp-diag-key">Resolved endpoint</dt>
|
||||
<dd className="sp-diag-val sp-accent">{snapshot.resolved_url}</dd>
|
||||
</div>
|
||||
)}
|
||||
{snapshot.last_error && (
|
||||
<div className="sp-diag-row">
|
||||
<dt className="sp-diag-key">Last error</dt>
|
||||
<dd className="sp-diag-val sp-danger">{snapshot.last_error.message}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-action-bar">
|
||||
<div className="action-bar-inner">
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn sp-btn--ghost btn-discard"
|
||||
onClick={resetDraft}
|
||||
disabled={busyAll || !draftDirty}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn sp-btn--secondary btn-save"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={busyAll || !draftDirty}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{isLocal && !isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn sp-btn--primary btn-save"
|
||||
onClick={() => void handleSaveAndStart()}
|
||||
disabled={busyAll || !Boolean(draftConfig.model_path)}
|
||||
>
|
||||
{saving ? "Starting…" : "Save & Start"}
|
||||
</button>
|
||||
)}
|
||||
{isLocal && isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
className="sp-btn sp-btn--danger btn-stop"
|
||||
onClick={() => void handleStop()}
|
||||
disabled={busyAll || isStopping}
|
||||
>
|
||||
{isStopping ? "Stopping…" : "Stop runtime"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user