- 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
963 lines
39 KiB
TypeScript
963 lines
39 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
getModelLibraryState,
|
||
pickRuntimeModelFiles,
|
||
pickRuntimeModelsDir,
|
||
queryRuntimeGpu,
|
||
scanRuntimeModels,
|
||
searchHuggingFaceModels,
|
||
} from "../api";
|
||
import {
|
||
chooseModel,
|
||
createRuntimeConfigPatch,
|
||
dedupeModels,
|
||
getLocalRuntimeStartReason,
|
||
pathName,
|
||
resolveDraftModelSelection,
|
||
toModelFile,
|
||
} from "../runtime-settings-form";
|
||
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime-defaults";
|
||
import {
|
||
createRuntimeDraftFieldState,
|
||
type ModelLibraryState,
|
||
type HuggingFaceModelResult,
|
||
type RuntimeConfig,
|
||
type RuntimeDraftFieldKey,
|
||
type RuntimeDraftFieldMeta,
|
||
type RuntimeDraftFieldState,
|
||
type RuntimeGpuInfo,
|
||
type RuntimeLifecycleResult,
|
||
type RuntimeModelFile,
|
||
type RuntimeSnapshot,
|
||
} from "../types";
|
||
import "./RuntimeSettingsDrawer.css";
|
||
|
||
const REMOTE_PRESET_TEMPLATES = [
|
||
{ id: "openai", label: "OpenAI", provider: "open_ai", baseUrl: "https://api.openai.com/v1" },
|
||
{ id: "openrouter", label: "OpenRouter", provider: "open_router", baseUrl: "https://openrouter.ai/api/v1" },
|
||
{ id: "anthropic", label: "Anthropic", provider: "anthropic", baseUrl: "https://api.anthropic.com/v1" },
|
||
] as const;
|
||
|
||
interface Props {
|
||
open: boolean;
|
||
snapshot: RuntimeSnapshot;
|
||
busy: boolean;
|
||
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
|
||
lastLifecycleResult?: RuntimeLifecycleResult;
|
||
lifecycleBusy?: boolean;
|
||
applyImpact?: "immediate" | "restart_required" | "save_before_apply";
|
||
onClose: () => void;
|
||
onSave: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||
onStart: () => Promise<unknown>;
|
||
onStop: () => Promise<unknown>;
|
||
draftConfig?: RuntimeConfig;
|
||
savedBaseline?: RuntimeConfig;
|
||
draftDirty?: boolean;
|
||
draftFields?: RuntimeDraftFieldState;
|
||
onUpdateDraft?: (patch: Partial<RuntimeConfig>) => void;
|
||
onUpdateDraftFieldMeta?: (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => void;
|
||
onResetDraft?: () => void;
|
||
}
|
||
|
||
function valuesEqual(left: unknown, right: unknown): boolean {
|
||
return JSON.stringify(left) === JSON.stringify(right);
|
||
}
|
||
|
||
function maskSecret(value: string): string {
|
||
if (!value.trim()) return "Not configured";
|
||
return `${"*".repeat(Math.max(4, Math.min(12, value.trim().length)))} (${value.trim().length} chars)`;
|
||
}
|
||
|
||
function formatReferenceValue(field: keyof RuntimeConfig, value: RuntimeConfig[keyof RuntimeConfig]): string {
|
||
switch (field) {
|
||
case "connection_mode":
|
||
return value === "remote" ? "Remote API" : "Local API";
|
||
case "start_policy":
|
||
return value === "on_app_launch" ? "Start on app launch" : "Start manually";
|
||
case "server_url":
|
||
return String(value || "Not configured");
|
||
case "api_key":
|
||
return maskSecret(String(value || ""));
|
||
case "model_path":
|
||
return value ? pathName(String(value)) : "No model selected";
|
||
case "models_dir":
|
||
return String(value || "No folder selected");
|
||
case "addr":
|
||
return `http://${String(value || "127.0.0.1:8080")}`;
|
||
case "gpu_layers":
|
||
return `${String(value)} GPU layers`;
|
||
case "ctx_size":
|
||
return `${String(value)} context tokens`;
|
||
case "selected_model_id":
|
||
return String(value || "No managed model selected");
|
||
case "hugging_face_token":
|
||
return maskSecret(String(value || ""));
|
||
default:
|
||
return Array.isArray(value) ? `${value.length} configured` : String(value || "-");
|
||
}
|
||
}
|
||
|
||
export default function RuntimeSettingsDrawer({
|
||
open,
|
||
snapshot,
|
||
busy,
|
||
activeLifecycleCommand = snapshot.active_command,
|
||
lastLifecycleResult = snapshot.last_command_result,
|
||
lifecycleBusy = false,
|
||
applyImpact = "immediate",
|
||
onClose,
|
||
onSave,
|
||
onStart,
|
||
onStop,
|
||
draftConfig: controlledDraftConfig,
|
||
savedBaseline: controlledSavedBaseline,
|
||
draftDirty: controlledDraftDirty,
|
||
draftFields: controlledDraftFields,
|
||
onUpdateDraft,
|
||
onUpdateDraftFieldMeta,
|
||
onResetDraft,
|
||
}: Props) {
|
||
const draftConfig = controlledDraftConfig ?? snapshot.config;
|
||
const savedBaseline = controlledSavedBaseline ?? snapshot.config;
|
||
const draftFields = controlledDraftFields ?? createRuntimeDraftFieldState();
|
||
const draftDirty = controlledDraftDirty ?? !valuesEqual(draftConfig, savedBaseline);
|
||
const draftConfigRef = useRef(draftConfig);
|
||
const requestIdsRef = useRef<Record<string, number>>({});
|
||
const [scannedModels, setScannedModels] = useState<RuntimeModelFile[]>([]);
|
||
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
|
||
const [gpuError, setGpuError] = useState("");
|
||
const [localError, setLocalError] = useState("");
|
||
const [scanning, setScanning] = useState(false);
|
||
const [hfQuery, setHfQuery] = useState("");
|
||
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
|
||
const [hfLoading, setHfLoading] = useState(false);
|
||
const [libraryState, setLibraryState] = useState<ModelLibraryState>({ entries: [], managed_dir: "" });
|
||
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||
|
||
useEffect(() => {
|
||
draftConfigRef.current = draftConfig;
|
||
}, [draftConfig]);
|
||
|
||
const updateDraft = (patch: Partial<RuntimeConfig>) => {
|
||
onUpdateDraft?.(patch);
|
||
};
|
||
|
||
const updateDraftFieldMeta = (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => {
|
||
onUpdateDraftFieldMeta?.(field, patch);
|
||
};
|
||
|
||
const beginRequest = (key: string) => {
|
||
const nextId = (requestIdsRef.current[key] ?? 0) + 1;
|
||
requestIdsRef.current[key] = nextId;
|
||
return nextId;
|
||
};
|
||
|
||
const isLatestRequest = (key: string, requestId: number) => requestIdsRef.current[key] === requestId;
|
||
|
||
const extraModels = useMemo(
|
||
() => draftConfig.extra_model_paths.map(toModelFile),
|
||
[draftConfig.extra_model_paths],
|
||
);
|
||
|
||
const allModels = useMemo(() => {
|
||
const merged = dedupeModels([...scannedModels, ...extraModels]);
|
||
if (draftConfig.model_path && !merged.some((model) => model.path === draftConfig.model_path)) {
|
||
return [toModelFile(draftConfig.model_path), ...merged];
|
||
}
|
||
return merged;
|
||
}, [draftConfig.model_path, extraModels, scannedModels]);
|
||
|
||
const dirtyFieldCount = useMemo(() => {
|
||
const trackedFields: Array<keyof RuntimeConfig> = [
|
||
"connection_mode",
|
||
"server_url",
|
||
"api_key",
|
||
"models_dir",
|
||
"model_path",
|
||
"addr",
|
||
"ctx_size",
|
||
"gpu_layers",
|
||
"start_policy",
|
||
"hugging_face_token",
|
||
];
|
||
return trackedFields.filter((field) => !valuesEqual(draftConfig[field], savedBaseline[field])).length;
|
||
}, [draftConfig, savedBaseline]);
|
||
|
||
const hasInvalidDraft = Object.values(draftFields).some((meta) => meta?.invalid);
|
||
const hasPendingDraft = Object.values(draftFields).some((meta) => meta?.pending);
|
||
const hasBlockingDraftState = hasInvalidDraft || hasPendingDraft;
|
||
const hasPendingModeChange = draftConfig.connection_mode !== savedBaseline.connection_mode;
|
||
const restartRequiredWhileRunning =
|
||
draftDirty && draftConfig.connection_mode === "local" && snapshot.process_running && applyImpact === "restart_required";
|
||
const maxLayers = gpuInfo?.recommended_layers ?? 200;
|
||
const startReason = getLocalRuntimeStartReason({
|
||
mode: draftConfig.connection_mode,
|
||
binaryPath: snapshot.binary_path,
|
||
selectedModel: draftConfig.model_path || "",
|
||
});
|
||
|
||
const isFieldDirty = (field: keyof RuntimeConfig) => !valuesEqual(draftConfig[field], savedBaseline[field]);
|
||
const fieldMeta = (field: RuntimeDraftFieldKey) => draftFields[field] ?? { invalid: false, pending: false, message: null };
|
||
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
|
||
const applyGuidance = !draftDirty || draftConfig.connection_mode !== "local"
|
||
? null
|
||
: hasPendingModeChange || applyImpact === "save_before_apply"
|
||
? "Stop the local API or save first before applying this change."
|
||
: applyImpact === "restart_required"
|
||
? "Restart required to apply saved changes."
|
||
: "Applies immediately after save.";
|
||
const saveLabel = activeLifecycleCommand === "save" ? "Saving changes..." : "Save changes";
|
||
const tertiaryLabel = hasPendingModeChange
|
||
? "Save mode before applying"
|
||
: activeLifecycleCommand === "start"
|
||
? "Starting local API..."
|
||
: activeLifecycleCommand === "stop"
|
||
? "Stopping local API..."
|
||
: restartRequiredWhileRunning
|
||
? "Restart required to apply saved changes"
|
||
: snapshot.process_running
|
||
? "Stop local API"
|
||
: "Save and start local API";
|
||
|
||
const renderFieldState = (field: RuntimeDraftFieldKey) => {
|
||
const meta = fieldMeta(field);
|
||
const dirty = isFieldDirty(field);
|
||
|
||
if (!dirty && !meta.invalid && !meta.pending) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className={`runtime-field-state${dirty ? " is-draft" : ""}${meta.invalid ? " is-invalid" : ""}`}>
|
||
<div className="runtime-field-state-row">
|
||
{dirty && <span className="runtime-field-badge">Draft</span>}
|
||
{meta.invalid && <span className="runtime-field-badge runtime-field-badge-danger">Invalid</span>}
|
||
{meta.pending && <span className="runtime-field-badge runtime-field-badge-pending">Pending</span>}
|
||
{dirty && (
|
||
<>
|
||
<span className="runtime-field-state-label">Live now</span>
|
||
<span className="runtime-field-state-value">{formatReferenceValue(field, savedBaseline[field])}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
{meta.message && <div className="runtime-field-error-text">{meta.message}</div>}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const applyResolvedModelState = (models: RuntimeModelFile[]) => {
|
||
const currentDraft = draftConfigRef.current;
|
||
const resolved = resolveDraftModelSelection(currentDraft.model_path || "", models, currentDraft.extra_model_paths.map(toModelFile));
|
||
updateDraft({
|
||
model_path: resolved.nextModel || null,
|
||
selected_model_id:
|
||
resolved.invalid || resolved.nextModel === currentDraft.model_path
|
||
? currentDraft.selected_model_id
|
||
: null,
|
||
});
|
||
updateDraftFieldMeta("model_path", {
|
||
invalid: resolved.invalid,
|
||
pending: false,
|
||
message: resolved.message,
|
||
});
|
||
};
|
||
|
||
const loadModelLibrary = async () => {
|
||
const requestId = beginRequest("library");
|
||
try {
|
||
const state = await getModelLibraryState();
|
||
if (!isLatestRequest("library", requestId)) return;
|
||
setLibraryState(state);
|
||
} catch (error) {
|
||
if (!isLatestRequest("library", requestId)) return;
|
||
setLocalError(String(error));
|
||
}
|
||
};
|
||
|
||
const loadGpuInfo = async () => {
|
||
const requestId = beginRequest("gpu");
|
||
try {
|
||
const info = await queryRuntimeGpu();
|
||
if (!isLatestRequest("gpu", requestId)) return;
|
||
setGpuInfo(info);
|
||
setGpuError("");
|
||
if (info && draftConfigRef.current.gpu_layers === 0) {
|
||
updateDraft({ gpu_layers: info.recommended_layers });
|
||
}
|
||
} catch (error) {
|
||
if (!isLatestRequest("gpu", requestId)) return;
|
||
setGpuError(String(error));
|
||
}
|
||
};
|
||
|
||
const handleRescan = async (dir = draftConfigRef.current.models_dir || "") => {
|
||
if (!dir.trim()) return;
|
||
const requestId = beginRequest("model-selection");
|
||
setScanning(true);
|
||
setLocalError("");
|
||
updateDraftFieldMeta("models_dir", { pending: true, invalid: false, message: null });
|
||
updateDraftFieldMeta("model_path", { pending: true });
|
||
try {
|
||
const models = await scanRuntimeModels(dir.trim());
|
||
if (!isLatestRequest("model-selection", requestId)) return;
|
||
setScannedModels(models);
|
||
applyResolvedModelState(models);
|
||
updateDraftFieldMeta("models_dir", { pending: false, invalid: false, message: null });
|
||
} catch (error) {
|
||
if (!isLatestRequest("model-selection", requestId)) return;
|
||
setLocalError(String(error));
|
||
setScannedModels([]);
|
||
updateDraftFieldMeta("models_dir", { pending: false });
|
||
updateDraftFieldMeta("model_path", { pending: false });
|
||
} finally {
|
||
if (isLatestRequest("model-selection", requestId)) {
|
||
setScanning(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handlePickDir = async () => {
|
||
const requestId = beginRequest("model-selection");
|
||
setLocalError("");
|
||
updateDraftFieldMeta("models_dir", { pending: true, invalid: false, message: null });
|
||
updateDraftFieldMeta("model_path", { pending: true });
|
||
try {
|
||
const result = await pickRuntimeModelsDir();
|
||
if (!isLatestRequest("model-selection", requestId) || !result) return;
|
||
updateDraft({ models_dir: result.dir });
|
||
setScannedModels(result.models);
|
||
applyResolvedModelState(result.models);
|
||
updateDraftFieldMeta("models_dir", { pending: false, invalid: false, message: null });
|
||
} catch (error) {
|
||
if (!isLatestRequest("model-selection", requestId)) return;
|
||
setLocalError(String(error));
|
||
updateDraftFieldMeta("models_dir", { pending: false });
|
||
updateDraftFieldMeta("model_path", { pending: false });
|
||
}
|
||
};
|
||
|
||
const handlePickFiles = async () => {
|
||
const requestId = beginRequest("model-selection");
|
||
setLocalError("");
|
||
updateDraftFieldMeta("model_path", { pending: true });
|
||
try {
|
||
const picked = await pickRuntimeModelFiles();
|
||
if (!isLatestRequest("model-selection", requestId) || picked.length === 0) {
|
||
updateDraftFieldMeta("model_path", { pending: false });
|
||
return;
|
||
}
|
||
|
||
const currentDraft = draftConfigRef.current;
|
||
const nextExtra = dedupeModels([...currentDraft.extra_model_paths.map(toModelFile), ...picked]);
|
||
const nextModel = currentDraft.model_path || nextExtra[0]?.path || "";
|
||
updateDraft({
|
||
extra_model_paths: nextExtra.map((model) => model.path),
|
||
model_path: nextModel || null,
|
||
selected_model_id: currentDraft.model_path ? currentDraft.selected_model_id : null,
|
||
});
|
||
updateDraftFieldMeta("model_path", { pending: false, invalid: false, message: null });
|
||
} catch (error) {
|
||
if (!isLatestRequest("model-selection", requestId)) return;
|
||
setLocalError(String(error));
|
||
updateDraftFieldMeta("model_path", { pending: false });
|
||
}
|
||
};
|
||
|
||
const handleHubSearch = async () => {
|
||
if (!hfQuery.trim()) {
|
||
setHfResults([]);
|
||
return;
|
||
}
|
||
|
||
const requestId = beginRequest("hf-search");
|
||
setLocalError("");
|
||
setHfLoading(true);
|
||
try {
|
||
const results = await searchHuggingFaceModels(hfQuery.trim(), draftConfig.hugging_face_token.trim());
|
||
if (!isLatestRequest("hf-search", requestId)) return;
|
||
setHfResults(results);
|
||
} catch (error) {
|
||
if (!isLatestRequest("hf-search", requestId)) return;
|
||
setLocalError(String(error));
|
||
setHfResults([]);
|
||
} finally {
|
||
if (isLatestRequest("hf-search", requestId)) {
|
||
setHfLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
setLocalError("");
|
||
try {
|
||
await onSave(buildPatch());
|
||
setConfirmDiscard(false);
|
||
} catch (error) {
|
||
setLocalError(String(error));
|
||
}
|
||
};
|
||
|
||
const handleSaveAndStart = async () => {
|
||
setLocalError("");
|
||
|
||
if (snapshot.process_running) {
|
||
try {
|
||
await onStop();
|
||
} catch (error) {
|
||
setLocalError(String(error));
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const next = await onSave(buildPatch());
|
||
if (next.config.connection_mode !== "local") return;
|
||
if (!next.config.model_path?.trim()) return;
|
||
if (next.last_error?.code === "restart_required") return;
|
||
await onStart();
|
||
} catch (error) {
|
||
setLocalError(String(error));
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setConfirmDiscard(false);
|
||
setLocalError("");
|
||
setHfResults([]);
|
||
void loadModelLibrary();
|
||
void loadGpuInfo();
|
||
if (draftConfig.connection_mode === "local" && draftConfig.models_dir?.trim()) {
|
||
void handleRescan(draftConfig.models_dir);
|
||
}
|
||
}, [open]);
|
||
|
||
if (!open) return null;
|
||
|
||
const buildPatch = (): RuntimeConfig => ({
|
||
...createRuntimeConfigPatch(
|
||
savedBaseline,
|
||
{
|
||
mode: draftConfig.connection_mode,
|
||
startPolicy: draftConfig.start_policy,
|
||
serverUrl: draftConfig.server_url,
|
||
apiKey: draftConfig.api_key,
|
||
modelsDir: draftConfig.models_dir || "",
|
||
selectedModel: draftConfig.model_path || "",
|
||
selectedModelId: draftConfig.selected_model_id,
|
||
addr: draftConfig.addr,
|
||
gpuLayers: draftConfig.gpu_layers,
|
||
ctxSize: draftConfig.ctx_size,
|
||
extraModels,
|
||
},
|
||
maxLayers,
|
||
),
|
||
hugging_face_token: draftConfig.hugging_face_token.trim(),
|
||
remote_presets: draftConfig.remote_presets?.length
|
||
? draftConfig.remote_presets
|
||
: REMOTE_PRESET_TEMPLATES.map((preset) => ({
|
||
id: preset.id,
|
||
label: preset.label,
|
||
provider: preset.provider,
|
||
base_url: preset.baseUrl,
|
||
default_model: "",
|
||
})),
|
||
});
|
||
|
||
return (
|
||
<div className="runtime-drawer-shell" role="dialog" aria-modal="true" aria-label="Draft settings">
|
||
<button
|
||
className="runtime-drawer-backdrop"
|
||
onClick={() => {
|
||
setConfirmDiscard(false);
|
||
onClose();
|
||
}}
|
||
aria-label="Close runtime settings"
|
||
/>
|
||
<aside className="runtime-drawer">
|
||
<div className="runtime-drawer-header runtime-drawer-header-sticky">
|
||
<div>
|
||
<div className="runtime-drawer-kicker">Draft settings</div>
|
||
<h2>Draft settings</h2>
|
||
<div className="runtime-drawer-summary-row">
|
||
<span className={`runtime-drawer-summary-badge${draftDirty ? " is-dirty" : ""}`}>
|
||
{draftDirty ? `${dirtyFieldCount} unsaved ${dirtyFieldCount === 1 ? "change" : "changes"}` : "No draft changes yet"}
|
||
</span>
|
||
<span className="runtime-live-chip">Live now</span>
|
||
<span className="runtime-live-chip-value">
|
||
{snapshot.status === "ready"
|
||
? "Local API ready"
|
||
: snapshot.config.connection_mode === "remote"
|
||
? "Remote API mode"
|
||
: "Local API setup"}
|
||
</span>
|
||
</div>
|
||
<p className="runtime-drawer-copy">
|
||
Edit runtime settings as a draft. Close keeps this draft in-session until you save or discard it.
|
||
</p>
|
||
</div>
|
||
<button
|
||
className="runtime-drawer-close"
|
||
onClick={() => {
|
||
setConfirmDiscard(false);
|
||
onClose();
|
||
}}
|
||
type="button"
|
||
aria-label="Close runtime settings"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="runtime-drawer-body">
|
||
<section className="runtime-overview-card">
|
||
<div>
|
||
<div className="runtime-overview-label">Current status</div>
|
||
<div className="runtime-overview-value">
|
||
{snapshot.status === "ready"
|
||
? "Local API ready"
|
||
: snapshot.config.connection_mode === "remote"
|
||
? "Remote API mode"
|
||
: "Local API setup"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="runtime-overview-label">Endpoint</div>
|
||
<div className="runtime-overview-value runtime-overview-mono">
|
||
{snapshot.config.connection_mode === "remote"
|
||
? snapshot.config.server_url || "Not configured"
|
||
: `http://${snapshot.config.addr}`}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="runtime-overview-label">Saved model</div>
|
||
<div className="runtime-overview-value">
|
||
{savedBaseline.model_path ? pathName(savedBaseline.model_path) : "No model saved"}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="runtime-drawer-section">
|
||
<div className="runtime-drawer-section-title">Connection mode</div>
|
||
<div className="runtime-segmented" role="tablist" aria-label="Connection mode">
|
||
<button
|
||
className={`runtime-segmented-btn${draftConfig.connection_mode === "local" ? " active" : ""}${isFieldDirty("connection_mode") ? " is-draft" : ""}`}
|
||
onClick={() => updateDraft({ connection_mode: "local" })}
|
||
type="button"
|
||
>
|
||
Local API
|
||
</button>
|
||
<button
|
||
className={`runtime-segmented-btn${draftConfig.connection_mode === "remote" ? " active" : ""}${isFieldDirty("connection_mode") ? " is-draft" : ""}`}
|
||
onClick={() => updateDraft({ connection_mode: "remote" })}
|
||
type="button"
|
||
>
|
||
Remote API
|
||
</button>
|
||
</div>
|
||
{isFieldDirty("connection_mode") && (
|
||
<div className="runtime-field-state is-draft">
|
||
<div className="runtime-field-state-row">
|
||
<span className="runtime-field-badge">Draft</span>
|
||
<span className="runtime-field-state-label">Live now</span>
|
||
<span className="runtime-field-state-value">
|
||
{formatReferenceValue("connection_mode", savedBaseline.connection_mode)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{draftConfig.connection_mode === "remote" ? (
|
||
<section className="runtime-drawer-section runtime-section-card">
|
||
<div className="runtime-field-label">Provider presets</div>
|
||
<div className="runtime-preset-row runtime-preset-row-first">
|
||
{REMOTE_PRESET_TEMPLATES.map((preset) => (
|
||
<button
|
||
key={preset.id}
|
||
className={`runtime-preset${draftConfig.server_url.trim() === preset.baseUrl ? " active" : ""}`}
|
||
onClick={() => updateDraft({ server_url: preset.baseUrl })}
|
||
type="button"
|
||
>
|
||
{preset.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<label className="runtime-field-label" htmlFor="runtime-remote-url">Remote server URL</label>
|
||
<input
|
||
id="runtime-remote-url"
|
||
className={`runtime-field-input${isFieldDirty("server_url") ? " is-draft" : ""}${fieldMeta("server_url").invalid ? " is-invalid" : ""}`}
|
||
value={draftConfig.server_url}
|
||
onChange={(event) => updateDraft({ server_url: event.target.value })}
|
||
placeholder="https://api.example.com"
|
||
type="text"
|
||
/>
|
||
{renderFieldState("server_url")}
|
||
|
||
<label className="runtime-field-label" htmlFor="runtime-api-key">API key</label>
|
||
<input
|
||
id="runtime-api-key"
|
||
className={`runtime-field-input${isFieldDirty("api_key") ? " is-draft" : ""}${fieldMeta("api_key").invalid ? " is-invalid" : ""}`}
|
||
value={draftConfig.api_key}
|
||
onChange={(event) => updateDraft({ api_key: event.target.value })}
|
||
placeholder="sk-..."
|
||
type="password"
|
||
/>
|
||
{renderFieldState("api_key")}
|
||
</section>
|
||
) : (
|
||
<>
|
||
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
|
||
<div>
|
||
<div className="runtime-field-label">Managed endpoint</div>
|
||
<div className="runtime-static-value runtime-static-value-mono">http://{draftConfig.addr.trim() || snapshot.config.addr}</div>
|
||
</div>
|
||
<div>
|
||
<div className="runtime-field-label">Binary discovery</div>
|
||
<div className="runtime-static-value runtime-static-value-path">{snapshot.binary_path || "Binary not found yet"}</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="runtime-drawer-section runtime-section-card">
|
||
<div className="runtime-field-row runtime-field-row-top">
|
||
<div>
|
||
<div className="runtime-field-label">Managed library</div>
|
||
<div className="runtime-field-support">
|
||
App-managed downloads live outside your scanned folders so they survive app updates.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="runtime-static-value runtime-static-value-path">
|
||
{libraryState.managed_dir || "Loading managed storage path..."}
|
||
</div>
|
||
|
||
{libraryState.entries.length > 0 ? (
|
||
<div className="runtime-chip-list">
|
||
{libraryState.entries.map((entry) => (
|
||
<button
|
||
key={entry.id}
|
||
className={`runtime-chip runtime-chip-selectable${draftConfig.selected_model_id === entry.id ? " active" : ""}${isFieldDirty("model_path") ? " is-draft" : ""}`}
|
||
onClick={() => updateDraft({ model_path: entry.path, selected_model_id: entry.id })}
|
||
type="button"
|
||
title={entry.path}
|
||
>
|
||
{entry.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="runtime-field-support">No managed downloads yet.</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="runtime-drawer-section runtime-section-card">
|
||
<div className="runtime-field-row">
|
||
<div>
|
||
<div className="runtime-field-label">Models folder</div>
|
||
<div className={`runtime-static-value runtime-static-value-path${isFieldDirty("models_dir") ? " is-draft" : ""}${fieldMeta("models_dir").invalid ? " is-invalid" : ""}`}>
|
||
{draftConfig.models_dir || "Choose a folder or add individual files"}
|
||
</div>
|
||
</div>
|
||
<div className="runtime-inline-actions">
|
||
<button className="runtime-inline-btn" onClick={handlePickDir} type="button">Browse</button>
|
||
<button className="runtime-inline-btn" onClick={() => { void handleRescan(); }} type="button" disabled={!draftConfig.models_dir?.trim() || scanning}>
|
||
{scanning ? "Scanning..." : "Rescan"}
|
||
</button>
|
||
<button className="runtime-inline-btn" onClick={handlePickFiles} type="button">Add files</button>
|
||
</div>
|
||
</div>
|
||
{renderFieldState("models_dir")}
|
||
|
||
<label className="runtime-field-label" htmlFor="runtime-model-select">Selected model</label>
|
||
<select
|
||
id="runtime-model-select"
|
||
className={`runtime-field-input runtime-field-select${isFieldDirty("model_path") ? " is-draft" : ""}${fieldMeta("model_path").invalid ? " is-invalid" : ""}`}
|
||
value={draftConfig.model_path || ""}
|
||
onChange={(event) =>
|
||
updateDraft({
|
||
model_path: event.target.value || null,
|
||
selected_model_id: null,
|
||
})
|
||
}
|
||
>
|
||
<option value="">Choose a model</option>
|
||
{allModels.map((model) => (
|
||
<option key={model.path} value={model.path}>{model.name}</option>
|
||
))}
|
||
</select>
|
||
{renderFieldState("model_path")}
|
||
|
||
{extraModels.length > 0 && (
|
||
<div className="runtime-chip-list">
|
||
{extraModels.map((model) => (
|
||
<button
|
||
key={model.path}
|
||
className="runtime-chip"
|
||
onClick={() => {
|
||
const next = extraModels.filter((entry) => entry.path !== model.path);
|
||
updateDraft({
|
||
extra_model_paths: next.map((entry) => entry.path),
|
||
model_path:
|
||
draftConfig.model_path === model.path
|
||
? chooseModel("", scannedModels, next)
|
||
: draftConfig.model_path,
|
||
selected_model_id: draftConfig.model_path === model.path ? null : draftConfig.selected_model_id,
|
||
});
|
||
}}
|
||
type="button"
|
||
title={`Remove ${model.name}`}
|
||
>
|
||
{model.name}
|
||
<span>×</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="runtime-drawer-section runtime-section-card">
|
||
<div className="runtime-field-row runtime-field-row-top">
|
||
<div>
|
||
<div className="runtime-field-label">Discover from Hugging Face</div>
|
||
<div className="runtime-field-support">
|
||
Search public GGUF repos, preview verified files, and keep your local library as the primary runtime source.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<label className="runtime-field-label" htmlFor="runtime-hf-token">Optional Hugging Face token</label>
|
||
<input
|
||
id="runtime-hf-token"
|
||
className={`runtime-field-input${isFieldDirty("hugging_face_token") ? " is-draft" : ""}`}
|
||
value={draftConfig.hugging_face_token}
|
||
onChange={(event) => updateDraft({ hugging_face_token: event.target.value })}
|
||
placeholder="hf_..."
|
||
type="password"
|
||
/>
|
||
|
||
<div className="runtime-field-row runtime-field-row-top">
|
||
<div className="runtime-search-grow">
|
||
<label className="runtime-field-label" htmlFor="runtime-hf-search">Search public GGUF repos</label>
|
||
<input
|
||
id="runtime-hf-search"
|
||
className="runtime-field-input"
|
||
value={hfQuery}
|
||
onChange={(event) => setHfQuery(event.target.value)}
|
||
onInput={(event) => setHfQuery((event.target as HTMLInputElement).value)}
|
||
placeholder="phi-4, mistral, qwen..."
|
||
type="text"
|
||
/>
|
||
</div>
|
||
<button className="runtime-inline-btn" onClick={() => { void handleHubSearch(); }} type="button" disabled={hfLoading}>
|
||
{hfLoading ? "Searching..." : "Search Hub"}
|
||
</button>
|
||
</div>
|
||
|
||
{hfResults.length > 0 ? (
|
||
<div className="runtime-search-results" aria-label="Hugging Face search results">
|
||
{hfResults.map((result) => (
|
||
<article key={result.repo_id} className="runtime-search-card">
|
||
<div className="runtime-search-title">{result.repo_id}</div>
|
||
<div className="runtime-field-support">{result.author || "Unknown author"}</div>
|
||
<div className="runtime-verified-file-list">
|
||
{result.verified_files.map((file) => (
|
||
<div key={`${result.repo_id}:${file.path}`} className="runtime-verified-file">
|
||
<span>{file.path}</span>
|
||
<span className="runtime-verified-file-meta">
|
||
{file.quantization || "gguf"}
|
||
{file.size ? ` · ${Math.round(file.size / (1024 * 1024))} MiB` : ""}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="runtime-field-support">
|
||
{hfLoading ? "Looking up verified GGUF files..." : "Search for a model family to preview compatible local installs."}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
|
||
<div>
|
||
<label className="runtime-field-label" htmlFor="runtime-addr">Listen address</label>
|
||
<input
|
||
id="runtime-addr"
|
||
className={`runtime-field-input${isFieldDirty("addr") ? " is-draft" : ""}`}
|
||
value={draftConfig.addr}
|
||
onChange={(event) => updateDraft({ addr: event.target.value })}
|
||
placeholder="127.0.0.1:8080"
|
||
type="text"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="runtime-field-label" htmlFor="runtime-context">Context size</label>
|
||
<input
|
||
id="runtime-context"
|
||
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
|
||
min={MIN_CTX_SIZE}
|
||
step={512}
|
||
value={draftConfig.ctx_size}
|
||
onChange={(event) => updateDraft({ ctx_size: Math.max(MIN_CTX_SIZE, parseInt(event.target.value, 10) || DEFAULT_CTX_SIZE) })}
|
||
type="number"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="runtime-drawer-section runtime-section-card">
|
||
<div className="runtime-field-row runtime-field-row-top">
|
||
<div>
|
||
<div className="runtime-field-label">GPU layers</div>
|
||
<div className="runtime-field-support">
|
||
{gpuInfo ? `${gpuInfo.name} · recommended ${gpuInfo.recommended_layers}` : gpuError || "GPU detection unavailable"}
|
||
</div>
|
||
</div>
|
||
{gpuInfo && (
|
||
<button className="runtime-inline-btn" onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })} type="button">
|
||
Use recommended
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<input
|
||
className={`runtime-field-input${isFieldDirty("gpu_layers") ? " is-draft" : ""}`}
|
||
min={0}
|
||
max={maxLayers}
|
||
value={draftConfig.gpu_layers}
|
||
onChange={(event) => updateDraft({ gpu_layers: Math.max(0, Math.min(maxLayers, parseInt(event.target.value, 10) || 0)) })}
|
||
type="number"
|
||
/>
|
||
|
||
<div className="runtime-preset-row">
|
||
{CTX_PRESETS.map((preset) => (
|
||
<button
|
||
key={preset}
|
||
className={`runtime-preset${draftConfig.ctx_size === preset ? " active" : ""}`}
|
||
onClick={() => updateDraft({ ctx_size: preset })}
|
||
type="button"
|
||
>
|
||
{preset >= 1024 ? `${preset / 1024}k` : preset}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="runtime-drawer-section runtime-section-card">
|
||
<label className="runtime-checkbox-row">
|
||
<input
|
||
checked={draftConfig.start_policy === "on_app_launch"}
|
||
onChange={(event) => updateDraft({ start_policy: event.target.checked ? "on_app_launch" : "manual" })}
|
||
type="checkbox"
|
||
/>
|
||
<span>Start the local API automatically when the desktop app opens</span>
|
||
</label>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{(localError || snapshot.last_error) && (
|
||
<section className="runtime-drawer-section">
|
||
<div className="runtime-error-banner">
|
||
{localError || snapshot.last_error?.message || "Couldn't save your draft. Review the highlighted fields and try again — your unsaved changes are still here."}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{showLifecycleResult && (
|
||
<section className="runtime-drawer-section">
|
||
<div
|
||
className={`runtime-apply-guidance runtime-apply-guidance-${lastLifecycleResult.outcome}`}
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
{lastLifecycleResult.message}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{applyGuidance && (
|
||
<section className="runtime-drawer-section">
|
||
<div
|
||
className={`runtime-apply-guidance${applyImpact !== "immediate" ? " is-warning" : ""}`}
|
||
role="status"
|
||
>
|
||
{applyGuidance}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{!snapshot.process_running && draftConfig.connection_mode === "local" && startReason && (
|
||
<section className="runtime-drawer-section">
|
||
<div className="runtime-warning-banner">{startReason}</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
|
||
<div className="runtime-drawer-footer runtime-drawer-footer-sticky">
|
||
<div className="runtime-footer-main-actions">
|
||
<button
|
||
className="runtime-footer-btn runtime-footer-btn-secondary"
|
||
onClick={() => setConfirmDiscard(true)}
|
||
type="button"
|
||
disabled={busy || lifecycleBusy || !draftDirty}
|
||
>
|
||
Discard changes
|
||
</button>
|
||
<button
|
||
className="runtime-footer-btn runtime-footer-btn-primary"
|
||
onClick={() => { void handleSave(); }}
|
||
type="button"
|
||
disabled={busy || lifecycleBusy || !draftDirty || hasBlockingDraftState}
|
||
>
|
||
{saveLabel}
|
||
</button>
|
||
</div>
|
||
|
||
{draftConfig.connection_mode === "local" && (
|
||
<button
|
||
className="runtime-footer-btn runtime-footer-btn-tertiary"
|
||
onClick={() => { void handleSaveAndStart(); }}
|
||
type="button"
|
||
disabled={
|
||
busy ||
|
||
lifecycleBusy ||
|
||
hasBlockingDraftState ||
|
||
hasPendingModeChange ||
|
||
restartRequiredWhileRunning ||
|
||
(!snapshot.process_running && Boolean(startReason))
|
||
}
|
||
>
|
||
{tertiaryLabel}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{confirmDiscard && (
|
||
<div className="runtime-discard-confirmation">
|
||
<div className="runtime-discard-copy">Revert to saved: Replace all unsaved edits with the last saved values.</div>
|
||
<div className="runtime-inline-actions">
|
||
<button className="runtime-inline-btn" onClick={() => setConfirmDiscard(false)} type="button">
|
||
Keep editing
|
||
</button>
|
||
<button
|
||
className="runtime-inline-btn runtime-inline-btn-danger"
|
||
onClick={() => {
|
||
onResetDraft?.();
|
||
setConfirmDiscard(false);
|
||
}}
|
||
type="button"
|
||
>
|
||
Revert to saved
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|