Initial ForgeLM import
This commit is contained in:
commit
a392b9dc60
4296 files changed
+1212675
No files matched your search
@@ -0,0 +1,151 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { createDefaultRuntimeSnapshot, createRuntimeLifecycleResult } from "./runtime-utils";
|
||||
import type {
|
||||
ModelLibraryState,
|
||||
HuggingFaceModelResult,
|
||||
RuntimeConfig,
|
||||
RuntimeGpuInfo,
|
||||
RuntimeModelFile,
|
||||
RuntimePickDirResult,
|
||||
RuntimeSnapshot,
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "forgelm_runtime_snapshot_v1";
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
function readFallbackSnapshot(): RuntimeSnapshot {
|
||||
if (!canUseLocalStorage()) {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
|
||||
try {
|
||||
return { ...createDefaultRuntimeSnapshot(), ...JSON.parse(raw) } as RuntimeSnapshot;
|
||||
} catch {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
function writeFallbackSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
|
||||
if (canUseLocalStorage()) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function bootstrapRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("bootstrap_runtime_command");
|
||||
} catch {
|
||||
return readFallbackSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRuntimeSnapshot(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("get_runtime_snapshot_command");
|
||||
} catch {
|
||||
return readFallbackSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveRuntimeConfig(config: RuntimeConfig): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("save_runtime_config_command_handler", { config });
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({
|
||||
...snapshot,
|
||||
active_command: "idle",
|
||||
last_command_result: createRuntimeLifecycleResult("save", "Save completed."),
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("start_runtime_command");
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({
|
||||
...snapshot,
|
||||
status: "error",
|
||||
active_command: "idle",
|
||||
last_command_result: createRuntimeLifecycleResult(
|
||||
"start",
|
||||
"Local runtime control requires the Tauri desktop app.",
|
||||
"failed",
|
||||
),
|
||||
last_error: { code: "tauri_unavailable", message: "Local runtime control requires the Tauri desktop app." },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("stop_runtime_command");
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({
|
||||
...snapshot,
|
||||
status: "stopped",
|
||||
process_running: false,
|
||||
ready: false,
|
||||
active_command: "idle",
|
||||
last_command_result: createRuntimeLifecycleResult("stop", "Local API stopped."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryRuntimeGpu(): Promise<RuntimeGpuInfo | null> {
|
||||
try {
|
||||
return await invoke<RuntimeGpuInfo>("query_gpu");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanRuntimeModels(dir: string): Promise<RuntimeModelFile[]> {
|
||||
return invoke<RuntimeModelFile[]>("scan_models", { dir });
|
||||
}
|
||||
|
||||
export async function pickRuntimeModelsDir(): Promise<RuntimePickDirResult | null> {
|
||||
try {
|
||||
return await invoke<RuntimePickDirResult>("pick_models_dir");
|
||||
} catch (error) {
|
||||
if (String(error) === "cancelled") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickRuntimeModelFiles(): Promise<RuntimeModelFile[]> {
|
||||
try {
|
||||
return await invoke<RuntimeModelFile[]>("pick_model_files");
|
||||
} catch (error) {
|
||||
if (String(error) === "cancelled") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchHuggingFaceModels(query: string, token = ""): Promise<HuggingFaceModelResult[]> {
|
||||
return invoke<HuggingFaceModelResult[]>("search_hugging_face_models_command", {
|
||||
query,
|
||||
token: token.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getModelLibraryState(): Promise<ModelLibraryState> {
|
||||
return invoke<ModelLibraryState>("get_model_library_state_command");
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
.runtime-drawer-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.runtime-drawer-backdrop {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: rgba(5, 6, 10, 0.58);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.runtime-drawer {
|
||||
width: min(560px, 100%);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(72, 166, 255, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, #10161f, #0b1017 72%);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: -28px 0 70px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.runtime-drawer-header,
|
||||
.runtime-drawer-footer {
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.runtime-drawer-header-sticky,
|
||||
.runtime-drawer-footer-sticky {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(16px);
|
||||
background: rgba(10, 16, 22, 0.92);
|
||||
}
|
||||
|
||||
.runtime-drawer-header-sticky {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.runtime-drawer-footer-sticky {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.runtime-drawer-footer {
|
||||
border-bottom: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-drawer-kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #78bdf4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.runtime-drawer-header h2 {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.runtime-drawer-copy {
|
||||
margin-top: 10px;
|
||||
max-width: 34ch;
|
||||
color: var(--text2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.runtime-drawer-summary-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.runtime-drawer-summary-badge,
|
||||
.runtime-live-chip,
|
||||
.runtime-live-chip-value,
|
||||
.runtime-field-badge {
|
||||
border-radius: 999px;
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-drawer-summary-badge,
|
||||
.runtime-live-chip-value {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.runtime-drawer-summary-badge.is-dirty,
|
||||
.runtime-field-badge {
|
||||
border: 1px solid rgba(72, 166, 255, 0.45);
|
||||
background: rgba(72, 166, 255, 0.16);
|
||||
color: #9cd2ff;
|
||||
}
|
||||
|
||||
.runtime-live-chip {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.runtime-field-badge-danger {
|
||||
border-color: rgba(240, 64, 64, 0.45);
|
||||
background: rgba(240, 64, 64, 0.14);
|
||||
color: #ffb7b7;
|
||||
}
|
||||
|
||||
.runtime-field-badge-pending {
|
||||
border-color: rgba(243, 211, 139, 0.32);
|
||||
background: rgba(243, 211, 139, 0.12);
|
||||
color: #f3d38b;
|
||||
}
|
||||
|
||||
.runtime-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-drawer-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 22px 28px;
|
||||
}
|
||||
|
||||
.runtime-drawer-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.runtime-section-card,
|
||||
.runtime-overview-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.runtime-section-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.runtime-overview-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.runtime-overview-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.runtime-overview-value {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.runtime-overview-mono,
|
||||
.runtime-static-value-mono {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-drawer-section-title,
|
||||
.runtime-field-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.runtime-segmented {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn,
|
||||
.runtime-footer-btn {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn {
|
||||
flex: 1;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn.active {
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: rgba(72, 166, 255, 0.16);
|
||||
}
|
||||
|
||||
.runtime-field-input,
|
||||
.runtime-static-value {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
padding: 11px 13px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.runtime-field-input.is-draft,
|
||||
.runtime-static-value.is-draft,
|
||||
.runtime-segmented-btn.is-draft,
|
||||
.runtime-chip-selectable.is-draft {
|
||||
border-color: rgba(72, 166, 255, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(72, 166, 255, 0.14);
|
||||
}
|
||||
|
||||
.runtime-field-input.is-invalid,
|
||||
.runtime-static-value.is-invalid {
|
||||
border-color: rgba(240, 64, 64, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(240, 64, 64, 0.12);
|
||||
}
|
||||
|
||||
.runtime-static-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.runtime-field-select {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.runtime-static-value-path {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.runtime-grid-two {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-search-grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.runtime-field-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.runtime-field-row-top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.runtime-field-support {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-inline-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.runtime-inline-btn,
|
||||
.runtime-preset,
|
||||
.runtime-chip {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-inline-btn {
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-inline-btn-danger {
|
||||
border-color: rgba(240, 64, 64, 0.45);
|
||||
background: rgba(240, 64, 64, 0.14);
|
||||
color: #ffb7b7;
|
||||
}
|
||||
|
||||
.runtime-inline-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-chip-selectable {
|
||||
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.runtime-chip-selectable.active {
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: rgba(72, 166, 255, 0.16);
|
||||
}
|
||||
|
||||
.runtime-chip span {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.runtime-preset-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-preset-row-first {
|
||||
margin-top: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.runtime-preset {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-preset.active {
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: rgba(72, 166, 255, 0.16);
|
||||
}
|
||||
|
||||
.runtime-search-results {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.runtime-search-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.runtime-search-title {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.runtime-verified-file-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.runtime-verified-file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(9, 14, 21, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.runtime-verified-file-meta {
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-checkbox-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-error-banner {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(240, 64, 64, 0.18);
|
||||
background: rgba(240, 64, 64, 0.08);
|
||||
color: #ffb7b7;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-warning-banner {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(243, 211, 139, 0.18);
|
||||
background: rgba(243, 211, 139, 0.08);
|
||||
color: #f3d38b;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-apply-guidance {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(72, 166, 255, 0.2);
|
||||
background: rgba(72, 166, 255, 0.08);
|
||||
color: #9cd2ff;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-apply-guidance.is-warning,
|
||||
.runtime-apply-guidance-blocked {
|
||||
border-color: rgba(243, 211, 139, 0.22);
|
||||
background: rgba(243, 211, 139, 0.08);
|
||||
color: #f3d38b;
|
||||
}
|
||||
|
||||
.runtime-apply-guidance-failed {
|
||||
border-color: rgba(240, 64, 64, 0.22);
|
||||
background: rgba(240, 64, 64, 0.08);
|
||||
color: #ffb7b7;
|
||||
}
|
||||
|
||||
.runtime-apply-guidance-succeeded {
|
||||
border-color: rgba(48, 209, 88, 0.22);
|
||||
background: rgba(48, 209, 88, 0.08);
|
||||
color: #baf0c8;
|
||||
}
|
||||
|
||||
.runtime-field-state {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.runtime-field-state.is-draft {
|
||||
border-color: rgba(72, 166, 255, 0.24);
|
||||
background: rgba(72, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
.runtime-field-state.is-invalid {
|
||||
border-color: rgba(240, 64, 64, 0.26);
|
||||
background: rgba(240, 64, 64, 0.08);
|
||||
}
|
||||
|
||||
.runtime-field-state-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.runtime-field-state-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.runtime-field-state-value,
|
||||
.runtime-field-error-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-field-state-value {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.runtime-field-error-text {
|
||||
margin-top: 6px;
|
||||
color: #ffb7b7;
|
||||
}
|
||||
|
||||
.runtime-footer-btn {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.runtime-footer-main-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.runtime-footer-btn-tertiary {
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
.runtime-discard-confirmation {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 14px 22px 20px;
|
||||
background: rgba(20, 32, 45, 0.94);
|
||||
}
|
||||
|
||||
.runtime-discard-copy {
|
||||
color: var(--text2);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.runtime-footer-btn:active:not(:disabled),
|
||||
.runtime-segmented-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.runtime-footer-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-footer-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-footer-btn-primary {
|
||||
color: #fff;
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: linear-gradient(135deg, #2c8ce8, #49c0ff);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.runtime-drawer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.runtime-overview-card,
|
||||
.runtime-grid-two,
|
||||
.runtime-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.runtime-drawer-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.runtime-footer-main-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
getModelLibraryState,
|
||||
pickRuntimeModelFiles,
|
||||
pickRuntimeModelsDir,
|
||||
queryRuntimeGpu,
|
||||
scanRuntimeModels,
|
||||
searchHuggingFaceModels,
|
||||
} from "../api";
|
||||
import {
|
||||
chooseModel,
|
||||
createRuntimeConfigPatch,
|
||||
dedupeModels,
|
||||
getLocalRuntimeStartReason,
|
||||
pathName,
|
||||
resolveDraftModelSelection,
|
||||
toModelFile,
|
||||
} from "../runtime-settings-form";
|
||||
import {
|
||||
createRuntimeDraftFieldState,
|
||||
type ModelLibraryState,
|
||||
type HuggingFaceModelResult,
|
||||
type RuntimeConfig,
|
||||
type RuntimeDraftFieldKey,
|
||||
type RuntimeDraftFieldMeta,
|
||||
type RuntimeDraftFieldState,
|
||||
type RuntimeGpuInfo,
|
||||
type RuntimeLifecycleResult,
|
||||
type RuntimeModelFile,
|
||||
type RuntimeSnapshot,
|
||||
} from "../types";
|
||||
import "./RuntimeSettingsDrawer.css";
|
||||
|
||||
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
|
||||
const REMOTE_PRESET_TEMPLATES = [
|
||||
{ id: "openai", label: "OpenAI", provider: "open_ai", baseUrl: "https://api.openai.com/v1" },
|
||||
{ id: "openrouter", label: "OpenRouter", provider: "open_router", baseUrl: "https://openrouter.ai/api/v1" },
|
||||
{ id: "anthropic", label: "Anthropic", provider: "anthropic", baseUrl: "https://api.anthropic.com/v1" },
|
||||
] as const;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
snapshot: RuntimeSnapshot;
|
||||
busy: boolean;
|
||||
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
|
||||
lastLifecycleResult?: RuntimeLifecycleResult;
|
||||
lifecycleBusy?: boolean;
|
||||
applyImpact?: "immediate" | "restart_required" | "save_before_apply";
|
||||
onClose: () => void;
|
||||
onSave: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||||
onStart: () => Promise<unknown>;
|
||||
onStop: () => Promise<unknown>;
|
||||
draftConfig?: RuntimeConfig;
|
||||
savedBaseline?: RuntimeConfig;
|
||||
draftDirty?: boolean;
|
||||
draftFields?: RuntimeDraftFieldState;
|
||||
onUpdateDraft?: (patch: Partial<RuntimeConfig>) => void;
|
||||
onUpdateDraftFieldMeta?: (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => void;
|
||||
onResetDraft?: () => void;
|
||||
}
|
||||
|
||||
function valuesEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (!value.trim()) return "Not configured";
|
||||
return `${"*".repeat(Math.max(4, Math.min(12, value.trim().length)))} (${value.trim().length} chars)`;
|
||||
}
|
||||
|
||||
function formatReferenceValue(field: keyof RuntimeConfig, value: RuntimeConfig[keyof RuntimeConfig]): string {
|
||||
switch (field) {
|
||||
case "connection_mode":
|
||||
return value === "remote" ? "Remote API" : "Local API";
|
||||
case "start_policy":
|
||||
return value === "on_app_launch" ? "Start on app launch" : "Start manually";
|
||||
case "server_url":
|
||||
return String(value || "Not configured");
|
||||
case "api_key":
|
||||
return maskSecret(String(value || ""));
|
||||
case "model_path":
|
||||
return value ? pathName(String(value)) : "No model selected";
|
||||
case "models_dir":
|
||||
return String(value || "No folder selected");
|
||||
case "addr":
|
||||
return `http://${String(value || "127.0.0.1:8080")}`;
|
||||
case "gpu_layers":
|
||||
return `${String(value)} GPU layers`;
|
||||
case "ctx_size":
|
||||
return `${String(value)} context tokens`;
|
||||
case "selected_model_id":
|
||||
return String(value || "No managed model selected");
|
||||
case "hugging_face_token":
|
||||
return maskSecret(String(value || ""));
|
||||
default:
|
||||
return Array.isArray(value) ? `${value.length} configured` : String(value || "-");
|
||||
}
|
||||
}
|
||||
|
||||
export default function RuntimeSettingsDrawer({
|
||||
open,
|
||||
snapshot,
|
||||
busy,
|
||||
activeLifecycleCommand = snapshot.active_command,
|
||||
lastLifecycleResult = snapshot.last_command_result,
|
||||
lifecycleBusy = false,
|
||||
applyImpact = "immediate",
|
||||
onClose,
|
||||
onSave,
|
||||
onStart,
|
||||
onStop,
|
||||
draftConfig: controlledDraftConfig,
|
||||
savedBaseline: controlledSavedBaseline,
|
||||
draftDirty: controlledDraftDirty,
|
||||
draftFields: controlledDraftFields,
|
||||
onUpdateDraft,
|
||||
onUpdateDraftFieldMeta,
|
||||
onResetDraft,
|
||||
}: Props) {
|
||||
const draftConfig = controlledDraftConfig ?? snapshot.config;
|
||||
const savedBaseline = controlledSavedBaseline ?? snapshot.config;
|
||||
const draftFields = controlledDraftFields ?? createRuntimeDraftFieldState();
|
||||
const draftDirty = controlledDraftDirty ?? !valuesEqual(draftConfig, savedBaseline);
|
||||
const draftConfigRef = useRef(draftConfig);
|
||||
const requestIdsRef = useRef<Record<string, number>>({});
|
||||
const [scannedModels, setScannedModels] = useState<RuntimeModelFile[]>([]);
|
||||
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
|
||||
const [gpuError, setGpuError] = useState("");
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [hfQuery, setHfQuery] = useState("");
|
||||
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
|
||||
const [hfLoading, setHfLoading] = useState(false);
|
||||
const [libraryState, setLibraryState] = useState<ModelLibraryState>({ entries: [], managed_dir: "" });
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
draftConfigRef.current = draftConfig;
|
||||
}, [draftConfig]);
|
||||
|
||||
const updateDraft = (patch: Partial<RuntimeConfig>) => {
|
||||
onUpdateDraft?.(patch);
|
||||
};
|
||||
|
||||
const updateDraftFieldMeta = (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => {
|
||||
onUpdateDraftFieldMeta?.(field, patch);
|
||||
};
|
||||
|
||||
const beginRequest = (key: string) => {
|
||||
const nextId = (requestIdsRef.current[key] ?? 0) + 1;
|
||||
requestIdsRef.current[key] = nextId;
|
||||
return nextId;
|
||||
};
|
||||
|
||||
const isLatestRequest = (key: string, requestId: number) => requestIdsRef.current[key] === requestId;
|
||||
|
||||
const extraModels = useMemo(
|
||||
() => draftConfig.extra_model_paths.map(toModelFile),
|
||||
[draftConfig.extra_model_paths],
|
||||
);
|
||||
|
||||
const allModels = useMemo(() => {
|
||||
const merged = dedupeModels([...scannedModels, ...extraModels]);
|
||||
if (draftConfig.model_path && !merged.some((model) => model.path === draftConfig.model_path)) {
|
||||
return [toModelFile(draftConfig.model_path), ...merged];
|
||||
}
|
||||
return merged;
|
||||
}, [draftConfig.model_path, extraModels, scannedModels]);
|
||||
|
||||
const dirtyFieldCount = useMemo(() => {
|
||||
const trackedFields: Array<keyof RuntimeConfig> = [
|
||||
"connection_mode",
|
||||
"server_url",
|
||||
"api_key",
|
||||
"models_dir",
|
||||
"model_path",
|
||||
"addr",
|
||||
"ctx_size",
|
||||
"gpu_layers",
|
||||
"start_policy",
|
||||
"hugging_face_token",
|
||||
];
|
||||
return trackedFields.filter((field) => !valuesEqual(draftConfig[field], savedBaseline[field])).length;
|
||||
}, [draftConfig, savedBaseline]);
|
||||
|
||||
const hasInvalidDraft = Object.values(draftFields).some((meta) => meta?.invalid);
|
||||
const hasPendingDraft = Object.values(draftFields).some((meta) => meta?.pending);
|
||||
const hasBlockingDraftState = hasInvalidDraft || hasPendingDraft;
|
||||
const hasPendingModeChange = draftConfig.connection_mode !== savedBaseline.connection_mode;
|
||||
const restartRequiredWhileRunning =
|
||||
draftDirty && draftConfig.connection_mode === "local" && snapshot.process_running && applyImpact === "restart_required";
|
||||
const maxLayers = gpuInfo?.recommended_layers ?? 200;
|
||||
const startReason = getLocalRuntimeStartReason({
|
||||
mode: draftConfig.connection_mode,
|
||||
binaryPath: snapshot.binary_path,
|
||||
selectedModel: draftConfig.model_path || "",
|
||||
});
|
||||
|
||||
const isFieldDirty = (field: keyof RuntimeConfig) => !valuesEqual(draftConfig[field], savedBaseline[field]);
|
||||
const fieldMeta = (field: RuntimeDraftFieldKey) => draftFields[field] ?? { invalid: false, pending: false, message: null };
|
||||
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
|
||||
const applyGuidance = !draftDirty || draftConfig.connection_mode !== "local"
|
||||
? null
|
||||
: hasPendingModeChange || applyImpact === "save_before_apply"
|
||||
? "Stop the local API or save first before applying this change."
|
||||
: applyImpact === "restart_required"
|
||||
? "Restart required to apply saved changes."
|
||||
: "Applies immediately after save.";
|
||||
const saveLabel = activeLifecycleCommand === "save" ? "Saving changes..." : "Save changes";
|
||||
const tertiaryLabel = hasPendingModeChange
|
||||
? "Save mode before applying"
|
||||
: activeLifecycleCommand === "start"
|
||||
? "Starting local API..."
|
||||
: activeLifecycleCommand === "stop"
|
||||
? "Stopping local API..."
|
||||
: restartRequiredWhileRunning
|
||||
? "Restart required to apply saved changes"
|
||||
: snapshot.process_running
|
||||
? "Stop local API"
|
||||
: "Save and start local API";
|
||||
|
||||
const renderFieldState = (field: RuntimeDraftFieldKey) => {
|
||||
const meta = fieldMeta(field);
|
||||
const dirty = isFieldDirty(field);
|
||||
|
||||
if (!dirty && !meta.invalid && !meta.pending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`runtime-field-state${dirty ? " is-draft" : ""}${meta.invalid ? " is-invalid" : ""}`}>
|
||||
<div className="runtime-field-state-row">
|
||||
{dirty && <span className="runtime-field-badge">Draft</span>}
|
||||
{meta.invalid && <span className="runtime-field-badge runtime-field-badge-danger">Invalid</span>}
|
||||
{meta.pending && <span className="runtime-field-badge runtime-field-badge-pending">Pending</span>}
|
||||
{dirty && (
|
||||
<>
|
||||
<span className="runtime-field-state-label">Live now</span>
|
||||
<span className="runtime-field-state-value">{formatReferenceValue(field, savedBaseline[field])}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{meta.message && <div className="runtime-field-error-text">{meta.message}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const applyResolvedModelState = (models: RuntimeModelFile[]) => {
|
||||
const currentDraft = draftConfigRef.current;
|
||||
const resolved = resolveDraftModelSelection(currentDraft.model_path || "", models, currentDraft.extra_model_paths.map(toModelFile));
|
||||
updateDraft({
|
||||
model_path: resolved.nextModel || null,
|
||||
selected_model_id:
|
||||
resolved.invalid || resolved.nextModel === currentDraft.model_path
|
||||
? currentDraft.selected_model_id
|
||||
: null,
|
||||
});
|
||||
updateDraftFieldMeta("model_path", {
|
||||
invalid: resolved.invalid,
|
||||
pending: false,
|
||||
message: resolved.message,
|
||||
});
|
||||
};
|
||||
|
||||
const loadModelLibrary = async () => {
|
||||
const requestId = beginRequest("library");
|
||||
try {
|
||||
const state = await getModelLibraryState();
|
||||
if (!isLatestRequest("library", requestId)) return;
|
||||
setLibraryState(state);
|
||||
} catch (error) {
|
||||
if (!isLatestRequest("library", requestId)) return;
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const loadGpuInfo = async () => {
|
||||
const requestId = beginRequest("gpu");
|
||||
try {
|
||||
const info = await queryRuntimeGpu();
|
||||
if (!isLatestRequest("gpu", requestId)) return;
|
||||
setGpuInfo(info);
|
||||
setGpuError("");
|
||||
if (info && draftConfigRef.current.gpu_layers === 0) {
|
||||
updateDraft({ gpu_layers: info.recommended_layers });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isLatestRequest("gpu", requestId)) return;
|
||||
setGpuError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescan = async (dir = draftConfigRef.current.models_dir || "") => {
|
||||
if (!dir.trim()) return;
|
||||
const requestId = beginRequest("model-selection");
|
||||
setScanning(true);
|
||||
setLocalError("");
|
||||
updateDraftFieldMeta("models_dir", { pending: true, invalid: false, message: null });
|
||||
updateDraftFieldMeta("model_path", { pending: true });
|
||||
try {
|
||||
const models = await scanRuntimeModels(dir.trim());
|
||||
if (!isLatestRequest("model-selection", requestId)) return;
|
||||
setScannedModels(models);
|
||||
applyResolvedModelState(models);
|
||||
updateDraftFieldMeta("models_dir", { pending: false, invalid: false, message: null });
|
||||
} catch (error) {
|
||||
if (!isLatestRequest("model-selection", requestId)) return;
|
||||
setLocalError(String(error));
|
||||
setScannedModels([]);
|
||||
updateDraftFieldMeta("models_dir", { pending: false });
|
||||
updateDraftFieldMeta("model_path", { pending: false });
|
||||
} finally {
|
||||
if (isLatestRequest("model-selection", requestId)) {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickDir = async () => {
|
||||
const requestId = beginRequest("model-selection");
|
||||
setLocalError("");
|
||||
updateDraftFieldMeta("models_dir", { pending: true, invalid: false, message: null });
|
||||
updateDraftFieldMeta("model_path", { pending: true });
|
||||
try {
|
||||
const result = await pickRuntimeModelsDir();
|
||||
if (!isLatestRequest("model-selection", requestId) || !result) return;
|
||||
updateDraft({ models_dir: result.dir });
|
||||
setScannedModels(result.models);
|
||||
applyResolvedModelState(result.models);
|
||||
updateDraftFieldMeta("models_dir", { pending: false, invalid: false, message: null });
|
||||
} catch (error) {
|
||||
if (!isLatestRequest("model-selection", requestId)) return;
|
||||
setLocalError(String(error));
|
||||
updateDraftFieldMeta("models_dir", { pending: false });
|
||||
updateDraftFieldMeta("model_path", { pending: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickFiles = async () => {
|
||||
const requestId = beginRequest("model-selection");
|
||||
setLocalError("");
|
||||
updateDraftFieldMeta("model_path", { pending: true });
|
||||
try {
|
||||
const picked = await pickRuntimeModelFiles();
|
||||
if (!isLatestRequest("model-selection", requestId) || picked.length === 0) {
|
||||
updateDraftFieldMeta("model_path", { pending: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDraft = draftConfigRef.current;
|
||||
const nextExtra = dedupeModels([...currentDraft.extra_model_paths.map(toModelFile), ...picked]);
|
||||
const nextModel = currentDraft.model_path || nextExtra[0]?.path || "";
|
||||
updateDraft({
|
||||
extra_model_paths: nextExtra.map((model) => model.path),
|
||||
model_path: nextModel || null,
|
||||
selected_model_id: currentDraft.model_path ? currentDraft.selected_model_id : null,
|
||||
});
|
||||
updateDraftFieldMeta("model_path", { pending: false, invalid: false, message: null });
|
||||
} catch (error) {
|
||||
if (!isLatestRequest("model-selection", requestId)) return;
|
||||
setLocalError(String(error));
|
||||
updateDraftFieldMeta("model_path", { pending: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleHubSearch = async () => {
|
||||
if (!hfQuery.trim()) {
|
||||
setHfResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = beginRequest("hf-search");
|
||||
setLocalError("");
|
||||
setHfLoading(true);
|
||||
try {
|
||||
const results = await searchHuggingFaceModels(hfQuery.trim(), draftConfig.hugging_face_token.trim());
|
||||
if (!isLatestRequest("hf-search", requestId)) return;
|
||||
setHfResults(results);
|
||||
} catch (error) {
|
||||
if (!isLatestRequest("hf-search", requestId)) return;
|
||||
setLocalError(String(error));
|
||||
setHfResults([]);
|
||||
} finally {
|
||||
if (isLatestRequest("hf-search", requestId)) {
|
||||
setHfLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
await onSave(buildPatch());
|
||||
setConfirmDiscard(false);
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndStart = async () => {
|
||||
setLocalError("");
|
||||
|
||||
if (snapshot.process_running) {
|
||||
try {
|
||||
await onStop();
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await onSave(buildPatch());
|
||||
if (next.config.connection_mode !== "local") return;
|
||||
if (!next.config.model_path?.trim()) return;
|
||||
if (next.last_error?.code === "restart_required") return;
|
||||
await onStart();
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setConfirmDiscard(false);
|
||||
setLocalError("");
|
||||
setHfResults([]);
|
||||
void loadModelLibrary();
|
||||
void loadGpuInfo();
|
||||
if (draftConfig.connection_mode === "local" && draftConfig.models_dir?.trim()) {
|
||||
void handleRescan(draftConfig.models_dir);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const buildPatch = (): RuntimeConfig => ({
|
||||
...createRuntimeConfigPatch(
|
||||
savedBaseline,
|
||||
{
|
||||
mode: draftConfig.connection_mode,
|
||||
startPolicy: draftConfig.start_policy,
|
||||
serverUrl: draftConfig.server_url,
|
||||
apiKey: draftConfig.api_key,
|
||||
modelsDir: draftConfig.models_dir || "",
|
||||
selectedModel: draftConfig.model_path || "",
|
||||
selectedModelId: draftConfig.selected_model_id,
|
||||
addr: draftConfig.addr,
|
||||
gpuLayers: draftConfig.gpu_layers,
|
||||
ctxSize: draftConfig.ctx_size,
|
||||
extraModels,
|
||||
},
|
||||
maxLayers,
|
||||
),
|
||||
hugging_face_token: draftConfig.hugging_face_token.trim(),
|
||||
remote_presets: draftConfig.remote_presets?.length
|
||||
? draftConfig.remote_presets
|
||||
: REMOTE_PRESET_TEMPLATES.map((preset) => ({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
provider: preset.provider,
|
||||
base_url: preset.baseUrl,
|
||||
default_model: "",
|
||||
})),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="runtime-drawer-shell" role="dialog" aria-modal="true" aria-label="Draft settings">
|
||||
<button
|
||||
className="runtime-drawer-backdrop"
|
||||
onClick={() => {
|
||||
setConfirmDiscard(false);
|
||||
onClose();
|
||||
}}
|
||||
aria-label="Close runtime settings"
|
||||
/>
|
||||
<aside className="runtime-drawer">
|
||||
<div className="runtime-drawer-header runtime-drawer-header-sticky">
|
||||
<div>
|
||||
<div className="runtime-drawer-kicker">Draft settings</div>
|
||||
<h2>Draft settings</h2>
|
||||
<div className="runtime-drawer-summary-row">
|
||||
<span className={`runtime-drawer-summary-badge${draftDirty ? " is-dirty" : ""}`}>
|
||||
{draftDirty ? `${dirtyFieldCount} unsaved ${dirtyFieldCount === 1 ? "change" : "changes"}` : "No draft changes yet"}
|
||||
</span>
|
||||
<span className="runtime-live-chip">Live now</span>
|
||||
<span className="runtime-live-chip-value">
|
||||
{snapshot.status === "ready"
|
||||
? "Local API ready"
|
||||
: snapshot.config.connection_mode === "remote"
|
||||
? "Remote API mode"
|
||||
: "Local API setup"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="runtime-drawer-copy">
|
||||
Edit runtime settings as a draft. Close keeps this draft in-session until you save or discard it.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="runtime-drawer-close"
|
||||
onClick={() => {
|
||||
setConfirmDiscard(false);
|
||||
onClose();
|
||||
}}
|
||||
type="button"
|
||||
aria-label="Close runtime settings"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="runtime-drawer-body">
|
||||
<section className="runtime-overview-card">
|
||||
<div>
|
||||
<div className="runtime-overview-label">Current status</div>
|
||||
<div className="runtime-overview-value">
|
||||
{snapshot.status === "ready"
|
||||
? "Local API ready"
|
||||
: snapshot.config.connection_mode === "remote"
|
||||
? "Remote API mode"
|
||||
: "Local API setup"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-overview-label">Endpoint</div>
|
||||
<div className="runtime-overview-value runtime-overview-mono">
|
||||
{snapshot.config.connection_mode === "remote"
|
||||
? snapshot.config.server_url || "Not configured"
|
||||
: `http://${snapshot.config.addr}`}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-overview-label">Saved model</div>
|
||||
<div className="runtime-overview-value">
|
||||
{savedBaseline.model_path ? pathName(savedBaseline.model_path) : "No model saved"}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-drawer-section-title">Connection mode</div>
|
||||
<div className="runtime-segmented" role="tablist" aria-label="Connection mode">
|
||||
<button
|
||||
className={`runtime-segmented-btn${draftConfig.connection_mode === "local" ? " active" : ""}${isFieldDirty("connection_mode") ? " is-draft" : ""}`}
|
||||
onClick={() => updateDraft({ connection_mode: "local" })}
|
||||
type="button"
|
||||
>
|
||||
Local API
|
||||
</button>
|
||||
<button
|
||||
className={`runtime-segmented-btn${draftConfig.connection_mode === "remote" ? " active" : ""}${isFieldDirty("connection_mode") ? " is-draft" : ""}`}
|
||||
onClick={() => updateDraft({ connection_mode: "remote" })}
|
||||
type="button"
|
||||
>
|
||||
Remote API
|
||||
</button>
|
||||
</div>
|
||||
{isFieldDirty("connection_mode") && (
|
||||
<div className="runtime-field-state is-draft">
|
||||
<div className="runtime-field-state-row">
|
||||
<span className="runtime-field-badge">Draft</span>
|
||||
<span className="runtime-field-state-label">Live now</span>
|
||||
<span className="runtime-field-state-value">
|
||||
{formatReferenceValue("connection_mode", savedBaseline.connection_mode)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{draftConfig.connection_mode === "remote" ? (
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-label">Provider presets</div>
|
||||
<div className="runtime-preset-row runtime-preset-row-first">
|
||||
{REMOTE_PRESET_TEMPLATES.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
className={`runtime-preset${draftConfig.server_url.trim() === preset.baseUrl ? " active" : ""}`}
|
||||
onClick={() => updateDraft({ server_url: preset.baseUrl })}
|
||||
type="button"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="runtime-field-label" htmlFor="runtime-remote-url">Remote server URL</label>
|
||||
<input
|
||||
id="runtime-remote-url"
|
||||
className={`runtime-field-input${isFieldDirty("server_url") ? " is-draft" : ""}${fieldMeta("server_url").invalid ? " is-invalid" : ""}`}
|
||||
value={draftConfig.server_url}
|
||||
onChange={(event) => updateDraft({ server_url: event.target.value })}
|
||||
placeholder="https://api.example.com"
|
||||
type="text"
|
||||
/>
|
||||
{renderFieldState("server_url")}
|
||||
|
||||
<label className="runtime-field-label" htmlFor="runtime-api-key">API key</label>
|
||||
<input
|
||||
id="runtime-api-key"
|
||||
className={`runtime-field-input${isFieldDirty("api_key") ? " is-draft" : ""}${fieldMeta("api_key").invalid ? " is-invalid" : ""}`}
|
||||
value={draftConfig.api_key}
|
||||
onChange={(event) => updateDraft({ api_key: event.target.value })}
|
||||
placeholder="sk-..."
|
||||
type="password"
|
||||
/>
|
||||
{renderFieldState("api_key")}
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
|
||||
<div>
|
||||
<div className="runtime-field-label">Managed endpoint</div>
|
||||
<div className="runtime-static-value runtime-static-value-mono">http://{draftConfig.addr.trim() || snapshot.config.addr}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-field-label">Binary discovery</div>
|
||||
<div className="runtime-static-value runtime-static-value-path">{snapshot.binary_path || "Binary not found yet"}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-row runtime-field-row-top">
|
||||
<div>
|
||||
<div className="runtime-field-label">Managed library</div>
|
||||
<div className="runtime-field-support">
|
||||
App-managed downloads live outside your scanned folders so they survive app updates.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="runtime-static-value runtime-static-value-path">
|
||||
{libraryState.managed_dir || "Loading managed storage path..."}
|
||||
</div>
|
||||
|
||||
{libraryState.entries.length > 0 ? (
|
||||
<div className="runtime-chip-list">
|
||||
{libraryState.entries.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
className={`runtime-chip runtime-chip-selectable${draftConfig.selected_model_id === entry.id ? " active" : ""}${isFieldDirty("model_path") ? " is-draft" : ""}`}
|
||||
onClick={() => updateDraft({ model_path: entry.path, selected_model_id: entry.id })}
|
||||
type="button"
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="runtime-field-support">No managed downloads yet.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-row">
|
||||
<div>
|
||||
<div className="runtime-field-label">Models folder</div>
|
||||
<div className={`runtime-static-value runtime-static-value-path${isFieldDirty("models_dir") ? " is-draft" : ""}${fieldMeta("models_dir").invalid ? " is-invalid" : ""}`}>
|
||||
{draftConfig.models_dir || "Choose a folder or add individual files"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-inline-actions">
|
||||
<button className="runtime-inline-btn" onClick={handlePickDir} type="button">Browse</button>
|
||||
<button className="runtime-inline-btn" onClick={() => { void handleRescan(); }} type="button" disabled={!draftConfig.models_dir?.trim() || scanning}>
|
||||
{scanning ? "Scanning..." : "Rescan"}
|
||||
</button>
|
||||
<button className="runtime-inline-btn" onClick={handlePickFiles} type="button">Add files</button>
|
||||
</div>
|
||||
</div>
|
||||
{renderFieldState("models_dir")}
|
||||
|
||||
<label className="runtime-field-label" htmlFor="runtime-model-select">Selected model</label>
|
||||
<select
|
||||
id="runtime-model-select"
|
||||
className={`runtime-field-input runtime-field-select${isFieldDirty("model_path") ? " is-draft" : ""}${fieldMeta("model_path").invalid ? " is-invalid" : ""}`}
|
||||
value={draftConfig.model_path || ""}
|
||||
onChange={(event) =>
|
||||
updateDraft({
|
||||
model_path: event.target.value || null,
|
||||
selected_model_id: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Choose a model</option>
|
||||
{allModels.map((model) => (
|
||||
<option key={model.path} value={model.path}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{renderFieldState("model_path")}
|
||||
|
||||
{extraModels.length > 0 && (
|
||||
<div className="runtime-chip-list">
|
||||
{extraModels.map((model) => (
|
||||
<button
|
||||
key={model.path}
|
||||
className="runtime-chip"
|
||||
onClick={() => {
|
||||
const next = extraModels.filter((entry) => entry.path !== model.path);
|
||||
updateDraft({
|
||||
extra_model_paths: next.map((entry) => entry.path),
|
||||
model_path:
|
||||
draftConfig.model_path === model.path
|
||||
? chooseModel("", scannedModels, next)
|
||||
: draftConfig.model_path,
|
||||
selected_model_id: draftConfig.model_path === model.path ? null : draftConfig.selected_model_id,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
title={`Remove ${model.name}`}
|
||||
>
|
||||
{model.name}
|
||||
<span>×</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-row runtime-field-row-top">
|
||||
<div>
|
||||
<div className="runtime-field-label">Discover from Hugging Face</div>
|
||||
<div className="runtime-field-support">
|
||||
Search public GGUF repos, preview verified files, and keep your local library as the primary runtime source.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="runtime-field-label" htmlFor="runtime-hf-token">Optional Hugging Face token</label>
|
||||
<input
|
||||
id="runtime-hf-token"
|
||||
className={`runtime-field-input${isFieldDirty("hugging_face_token") ? " is-draft" : ""}`}
|
||||
value={draftConfig.hugging_face_token}
|
||||
onChange={(event) => updateDraft({ hugging_face_token: event.target.value })}
|
||||
placeholder="hf_..."
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div className="runtime-field-row runtime-field-row-top">
|
||||
<div className="runtime-search-grow">
|
||||
<label className="runtime-field-label" htmlFor="runtime-hf-search">Search public GGUF repos</label>
|
||||
<input
|
||||
id="runtime-hf-search"
|
||||
className="runtime-field-input"
|
||||
value={hfQuery}
|
||||
onChange={(event) => setHfQuery(event.target.value)}
|
||||
onInput={(event) => setHfQuery((event.target as HTMLInputElement).value)}
|
||||
placeholder="phi-4, mistral, qwen..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<button className="runtime-inline-btn" onClick={() => { void handleHubSearch(); }} type="button" disabled={hfLoading}>
|
||||
{hfLoading ? "Searching..." : "Search Hub"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hfResults.length > 0 ? (
|
||||
<div className="runtime-search-results" aria-label="Hugging Face search results">
|
||||
{hfResults.map((result) => (
|
||||
<article key={result.repo_id} className="runtime-search-card">
|
||||
<div className="runtime-search-title">{result.repo_id}</div>
|
||||
<div className="runtime-field-support">{result.author || "Unknown author"}</div>
|
||||
<div className="runtime-verified-file-list">
|
||||
{result.verified_files.map((file) => (
|
||||
<div key={`${result.repo_id}:${file.path}`} className="runtime-verified-file">
|
||||
<span>{file.path}</span>
|
||||
<span className="runtime-verified-file-meta">
|
||||
{file.quantization || "gguf"}
|
||||
{file.size ? ` · ${Math.round(file.size / (1024 * 1024))} MiB` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="runtime-field-support">
|
||||
{hfLoading ? "Looking up verified GGUF files..." : "Search for a model family to preview compatible local installs."}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
|
||||
<div>
|
||||
<label className="runtime-field-label" htmlFor="runtime-addr">Listen address</label>
|
||||
<input
|
||||
id="runtime-addr"
|
||||
className={`runtime-field-input${isFieldDirty("addr") ? " is-draft" : ""}`}
|
||||
value={draftConfig.addr}
|
||||
onChange={(event) => updateDraft({ addr: event.target.value })}
|
||||
placeholder="127.0.0.1:8080"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="runtime-field-label" htmlFor="runtime-context">Context size</label>
|
||||
<input
|
||||
id="runtime-context"
|
||||
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
|
||||
min={512}
|
||||
step={512}
|
||||
value={draftConfig.ctx_size}
|
||||
onChange={(event) => updateDraft({ ctx_size: Math.max(512, parseInt(event.target.value, 10) || 4096) })}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-row runtime-field-row-top">
|
||||
<div>
|
||||
<div className="runtime-field-label">GPU layers</div>
|
||||
<div className="runtime-field-support">
|
||||
{gpuInfo ? `${gpuInfo.name} · recommended ${gpuInfo.recommended_layers}` : gpuError || "GPU detection unavailable"}
|
||||
</div>
|
||||
</div>
|
||||
{gpuInfo && (
|
||||
<button className="runtime-inline-btn" onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })} type="button">
|
||||
Use recommended
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className={`runtime-field-input${isFieldDirty("gpu_layers") ? " is-draft" : ""}`}
|
||||
min={0}
|
||||
max={maxLayers}
|
||||
value={draftConfig.gpu_layers}
|
||||
onChange={(event) => updateDraft({ gpu_layers: Math.max(0, Math.min(maxLayers, parseInt(event.target.value, 10) || 0)) })}
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<div className="runtime-preset-row">
|
||||
{CTX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
className={`runtime-preset${draftConfig.ctx_size === preset ? " active" : ""}`}
|
||||
onClick={() => updateDraft({ ctx_size: preset })}
|
||||
type="button"
|
||||
>
|
||||
{preset >= 1024 ? `${preset / 1024}k` : preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<label className="runtime-checkbox-row">
|
||||
<input
|
||||
checked={draftConfig.start_policy === "on_app_launch"}
|
||||
onChange={(event) => updateDraft({ start_policy: event.target.checked ? "on_app_launch" : "manual" })}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>Start the local API automatically when the desktop app opens</span>
|
||||
</label>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(localError || snapshot.last_error) && (
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-error-banner">
|
||||
{localError || snapshot.last_error?.message || "Couldn't save your draft. Review the highlighted fields and try again — your unsaved changes are still here."}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showLifecycleResult && (
|
||||
<section className="runtime-drawer-section">
|
||||
<div
|
||||
className={`runtime-apply-guidance runtime-apply-guidance-${lastLifecycleResult.outcome}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{lastLifecycleResult.message}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{applyGuidance && (
|
||||
<section className="runtime-drawer-section">
|
||||
<div
|
||||
className={`runtime-apply-guidance${applyImpact !== "immediate" ? " is-warning" : ""}`}
|
||||
role="status"
|
||||
>
|
||||
{applyGuidance}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!snapshot.process_running && draftConfig.connection_mode === "local" && startReason && (
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-warning-banner">{startReason}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="runtime-drawer-footer runtime-drawer-footer-sticky">
|
||||
<div className="runtime-footer-main-actions">
|
||||
<button
|
||||
className="runtime-footer-btn runtime-footer-btn-secondary"
|
||||
onClick={() => setConfirmDiscard(true)}
|
||||
type="button"
|
||||
disabled={busy || lifecycleBusy || !draftDirty}
|
||||
>
|
||||
Discard changes
|
||||
</button>
|
||||
<button
|
||||
className="runtime-footer-btn runtime-footer-btn-primary"
|
||||
onClick={() => { void handleSave(); }}
|
||||
type="button"
|
||||
disabled={busy || lifecycleBusy || !draftDirty || hasBlockingDraftState}
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{draftConfig.connection_mode === "local" && (
|
||||
<button
|
||||
className="runtime-footer-btn runtime-footer-btn-tertiary"
|
||||
onClick={() => { void handleSaveAndStart(); }}
|
||||
type="button"
|
||||
disabled={
|
||||
busy ||
|
||||
lifecycleBusy ||
|
||||
hasBlockingDraftState ||
|
||||
hasPendingModeChange ||
|
||||
restartRequiredWhileRunning ||
|
||||
(!snapshot.process_running && Boolean(startReason))
|
||||
}
|
||||
>
|
||||
{tertiaryLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmDiscard && (
|
||||
<div className="runtime-discard-confirmation">
|
||||
<div className="runtime-discard-copy">Revert to saved: Replace all unsaved edits with the last saved values.</div>
|
||||
<div className="runtime-inline-actions">
|
||||
<button className="runtime-inline-btn" onClick={() => setConfirmDiscard(false)} type="button">
|
||||
Keep editing
|
||||
</button>
|
||||
<button
|
||||
className="runtime-inline-btn runtime-inline-btn-danger"
|
||||
onClick={() => {
|
||||
onResetDraft?.();
|
||||
setConfirmDiscard(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Revert to saved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
.runtime-card {
|
||||
margin: 10px 12px 8px;
|
||||
padding: 15px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(72, 166, 255, 0.18), transparent 40%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01));
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.runtime-card-success {
|
||||
border-color: rgba(48, 209, 88, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-warning {
|
||||
border-color: rgba(255, 214, 10, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-danger {
|
||||
border-color: rgba(240, 64, 64, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-card-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.runtime-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.runtime-card-mode {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border2);
|
||||
color: var(--text2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.runtime-card-mode-local {
|
||||
color: #71c5ff;
|
||||
border-color: rgba(113, 197, 255, 0.28);
|
||||
}
|
||||
|
||||
.runtime-card-mode-remote {
|
||||
color: #92e5c7;
|
||||
border-color: rgba(146, 229, 199, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-copy {
|
||||
margin-top: 10px;
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-card-meta span,
|
||||
.runtime-card-note {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.runtime-card-note {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
border-radius: 12px;
|
||||
color: #f3d38b;
|
||||
border-color: rgba(243, 211, 139, 0.16);
|
||||
background: rgba(243, 211, 139, 0.08);
|
||||
}
|
||||
|
||||
.runtime-card-error {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(240, 64, 64, 0.2);
|
||||
background: rgba(240, 64, 64, 0.08);
|
||||
color: #ffb4b4;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-card-result {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-card-result-success {
|
||||
border-color: rgba(48, 209, 88, 0.22);
|
||||
background: rgba(48, 209, 88, 0.1);
|
||||
color: #baf0c8;
|
||||
}
|
||||
|
||||
.runtime-card-result-warning {
|
||||
border-color: rgba(243, 211, 139, 0.24);
|
||||
background: rgba(243, 211, 139, 0.1);
|
||||
color: #f3d38b;
|
||||
}
|
||||
|
||||
.runtime-card-result-danger {
|
||||
border-color: rgba(240, 64, 64, 0.22);
|
||||
background: rgba(240, 64, 64, 0.1);
|
||||
color: #ffb7b7;
|
||||
}
|
||||
|
||||
.runtime-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.runtime-card-btn {
|
||||
flex: 1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border2);
|
||||
padding: 9px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.runtime-card-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.runtime-card-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-card-btn-secondary {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.runtime-card-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary {
|
||||
color: #fff;
|
||||
border-color: rgba(72, 166, 255, 0.6);
|
||||
background: linear-gradient(135deg, #2b88e6, #46c2ff);
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #3193f5, #55cbff);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { getRuntimeStartGuard, getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime-utils";
|
||||
import type { RuntimeLifecycleResult, RuntimeSnapshot } from "../types";
|
||||
import "./RuntimeStatusCard.css";
|
||||
|
||||
function summarizePath(path: string | null): string {
|
||||
if (!path) return "No model selected";
|
||||
const segments = path.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
snapshot: RuntimeSnapshot;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
|
||||
lastLifecycleResult?: RuntimeLifecycleResult;
|
||||
lifecycleBusy?: boolean;
|
||||
onOpenSettings: () => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
function getLifecycleActionLabel(command: RuntimeSnapshot["active_command"]): string | null {
|
||||
switch (command) {
|
||||
case "start":
|
||||
return "Starting local API...";
|
||||
case "stop":
|
||||
return "Stopping local API...";
|
||||
case "save":
|
||||
return "Saving changes...";
|
||||
case "save_and_start":
|
||||
return "Saving and starting local API...";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RuntimeStatusCard({
|
||||
snapshot,
|
||||
loading,
|
||||
busy,
|
||||
activeLifecycleCommand = snapshot.active_command,
|
||||
lastLifecycleResult = snapshot.last_command_result,
|
||||
lifecycleBusy = false,
|
||||
onOpenSettings,
|
||||
onStart,
|
||||
onStop,
|
||||
}: Props) {
|
||||
const tone = getRuntimeStatusTone(snapshot);
|
||||
const isLocal = snapshot.config.connection_mode === "local";
|
||||
const startGuard = getRuntimeStartGuard(snapshot);
|
||||
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
|
||||
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
|
||||
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand);
|
||||
const primaryLabel = lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
|
||||
const primaryDisabled = busy || lifecycleBusy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
|
||||
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
|
||||
const lifecycleResultClass =
|
||||
lastLifecycleResult.outcome === "failed"
|
||||
? "runtime-card-result-danger"
|
||||
: lastLifecycleResult.outcome === "blocked"
|
||||
? "runtime-card-result-warning"
|
||||
: "runtime-card-result-success";
|
||||
|
||||
return (
|
||||
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
|
||||
<div className="runtime-card-header">
|
||||
<div>
|
||||
<div className="runtime-card-kicker">Runtime Manager</div>
|
||||
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
|
||||
</div>
|
||||
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
|
||||
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="runtime-card-copy">
|
||||
{snapshot.config.connection_mode === "local"
|
||||
? `Managed endpoint ${snapshot.resolved_url}`
|
||||
: snapshot.config.server_url || "Configure a remote API endpoint"}
|
||||
</p>
|
||||
|
||||
<div className="runtime-card-meta" aria-label="Runtime details">
|
||||
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
|
||||
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
|
||||
</div>
|
||||
|
||||
{needsSetup && <div className="runtime-card-note">{startGuard.reason}</div>}
|
||||
|
||||
{showLifecycleResult && (
|
||||
<div className={`runtime-card-result ${lifecycleResultClass}`} role="status" aria-live="polite">
|
||||
{lastLifecycleResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot.last_error && (
|
||||
<div className="runtime-card-error" role="status">
|
||||
{snapshot.last_error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="runtime-card-actions">
|
||||
<button className="runtime-card-btn runtime-card-btn-secondary" onClick={onOpenSettings} type="button" disabled={lifecycleBusy}>
|
||||
Manage
|
||||
</button>
|
||||
{snapshot.config.connection_mode === "local" && (
|
||||
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
|
||||
{primaryLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,267 @@
|
||||
.runtime-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--surface-elevated, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.runtime-workspace-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.runtime-workspace-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.runtime-workspace-header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.runtime-workspace-kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.runtime-workspace-mode {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-muted, #f5f5f5);
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.runtime-workspace-mode-local {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.runtime-workspace-mode-remote {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.runtime-workspace-status {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
}
|
||||
|
||||
.runtime-workspace-health {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.runtime-workspace-health-neutral {
|
||||
background: var(--bg-muted, #f5f5f5);
|
||||
}
|
||||
|
||||
.runtime-workspace-health-success {
|
||||
background: #e8f5e9;
|
||||
}
|
||||
|
||||
.runtime-workspace-health-warning {
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
.runtime-workspace-health-danger {
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.runtime-workspace-health-headline {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
}
|
||||
|
||||
.runtime-workspace-health-detail {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.runtime-workspace-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-muted, #f5f5f5);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.runtime-workspace-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-workspace-detail-label {
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.runtime-workspace-detail-value {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-workspace-result {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.runtime-workspace-result-success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.runtime-workspace-result-warning {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.runtime-workspace-result-danger {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.runtime-workspace-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.runtime-workspace-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.runtime-workspace-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-workspace-btn-primary {
|
||||
background: var(--primary, #0066cc);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.runtime-workspace-btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #0052a3);
|
||||
}
|
||||
|
||||
.runtime-workspace-btn-secondary {
|
||||
background: var(--bg-muted, #f5f5f5);
|
||||
color: var(--text-primary, #111);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.runtime-workspace-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #eee);
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--surface-elevated, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.runtime-workspace-apply-guidance {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.runtime-workspace-apply-guidance.is-warning {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #0066cc);
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-input.is-invalid {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.runtime-workspace-draft-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useRuntimeManager } from "../useRuntimeManager";
|
||||
import { getRuntimeHealthSummary, getRuntimeWorkspaceSummary } from "../runtime-utils";
|
||||
import type { RuntimeConfig, RuntimeDraftFieldKey, RuntimeDraftFieldMeta, RuntimeDraftFieldState, RuntimeSnapshot, RuntimeWorkspaceHealth, RuntimeWorkspaceSummary } from "../types";
|
||||
import "./RuntimeWorkspace.css";
|
||||
|
||||
interface ExternalRuntimeProps {
|
||||
snapshot: RuntimeSnapshot;
|
||||
savedBaseline: RuntimeConfig;
|
||||
draftConfig: RuntimeConfig;
|
||||
draftDirty: boolean;
|
||||
draftOpen?: boolean;
|
||||
draftFields: RuntimeDraftFieldState;
|
||||
loading?: boolean;
|
||||
busy?: boolean;
|
||||
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
|
||||
lastLifecycleResult?: RuntimeSnapshot["last_command_result"];
|
||||
lifecycleBusy?: boolean;
|
||||
applyImpact?: "immediate" | "restart_required" | "save_before_apply";
|
||||
openDraft?: () => void;
|
||||
closeDraft?: () => void;
|
||||
resetDraft?: () => void;
|
||||
updateDraft?: (patch: Partial<RuntimeConfig>) => void;
|
||||
updateDraftField?: <K extends RuntimeDraftFieldKey>(field: K, value: RuntimeConfig[K]) => void;
|
||||
updateDraftFieldMeta?: (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => void;
|
||||
saveConfig?: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||||
startLocal?: () => Promise<unknown>;
|
||||
stopLocal?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
runtime?: ExternalRuntimeProps;
|
||||
}
|
||||
|
||||
function summarizePath(path: string | null): string {
|
||||
if (!path) return "No model selected";
|
||||
const segments = path.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
function getLifecycleActionLabel(command: string): string | null {
|
||||
switch (command) {
|
||||
case "start":
|
||||
return "Starting local API...";
|
||||
case "stop":
|
||||
return "Stopping local API...";
|
||||
case "save":
|
||||
return "Saving changes...";
|
||||
case "save_and_start":
|
||||
return "Saving and starting local API...";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RuntimeWorkspace({ runtime: externalRuntime }: Props) {
|
||||
const internalManager = useRuntimeManager();
|
||||
|
||||
// Use external runtime props if provided, otherwise use internal manager
|
||||
const useExternal = !!externalRuntime;
|
||||
const runtime = useExternal ? externalRuntime : internalManager;
|
||||
|
||||
// Compute workspace summary and health from snapshot when using external props
|
||||
const workspaceSummary: RuntimeWorkspaceSummary = useExternal
|
||||
? getRuntimeWorkspaceSummary(runtime.snapshot)
|
||||
: internalManager.workspaceSummary;
|
||||
|
||||
const workspaceHealth: RuntimeWorkspaceHealth = useExternal
|
||||
? getRuntimeHealthSummary(runtime.snapshot)
|
||||
: internalManager.workspaceHealth;
|
||||
|
||||
const {
|
||||
draftDirty,
|
||||
draftConfig,
|
||||
savedBaseline,
|
||||
draftFields,
|
||||
applyImpact,
|
||||
busy,
|
||||
activeLifecycleCommand,
|
||||
lastLifecycleResult,
|
||||
lifecycleBusy,
|
||||
loading,
|
||||
openDraft,
|
||||
closeDraft,
|
||||
resetDraft,
|
||||
updateDraft,
|
||||
updateDraftField,
|
||||
updateDraftFieldMeta,
|
||||
saveConfig,
|
||||
startLocal,
|
||||
stopLocal,
|
||||
snapshot,
|
||||
} = runtime;
|
||||
|
||||
const isLocal = snapshot.config.connection_mode === "local";
|
||||
const lifecycleResult = lastLifecycleResult ?? { command: "idle" as const, outcome: "succeeded" as const, message: "" };
|
||||
const showLifecycleResult = lifecycleResult.command !== "idle" && !lifecycleBusy;
|
||||
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand ?? "");
|
||||
|
||||
const hasInvalidDraft = Object.values(draftFields).some((meta) => meta?.invalid);
|
||||
const hasPendingDraft = Object.values(draftFields).some((meta) => meta?.pending);
|
||||
const hasBlockingDraftState = hasInvalidDraft || hasPendingDraft;
|
||||
const hasPendingModeChange = draftConfig.connection_mode !== savedBaseline.connection_mode;
|
||||
const restartRequiredWhileRunning =
|
||||
draftDirty && draftConfig.connection_mode === "local" && snapshot.process_running && applyImpact === "restart_required";
|
||||
|
||||
const applyGuidance = !draftDirty || draftConfig.connection_mode !== "local"
|
||||
? null
|
||||
: hasPendingModeChange || applyImpact === "save_before_apply"
|
||||
? "Stop the local API or save first before applying this change."
|
||||
: applyImpact === "restart_required"
|
||||
? "Restart required to apply saved changes."
|
||||
: "Applies immediately after save.";
|
||||
|
||||
const lifecycleResultClass =
|
||||
lifecycleResult.outcome === "failed"
|
||||
? "runtime-workspace-result-danger"
|
||||
: lifecycleResult.outcome === "blocked"
|
||||
? "runtime-workspace-result-warning"
|
||||
: "runtime-workspace-result-success";
|
||||
|
||||
const healthToneClass = `runtime-workspace-health-${workspaceHealth.tone}`;
|
||||
|
||||
const saveLabel = activeLifecycleCommand === "save" ? "Saving changes..." : "Save changes";
|
||||
const tertiaryLabel = hasPendingModeChange
|
||||
? "Save mode before applying"
|
||||
: activeLifecycleCommand === "start"
|
||||
? "Starting local API..."
|
||||
: activeLifecycleCommand === "stop"
|
||||
? "Stopping local API..."
|
||||
: restartRequiredWhileRunning
|
||||
? "Restart required to apply saved changes"
|
||||
: snapshot.process_running
|
||||
? "Stop local API"
|
||||
: "Save and start local API";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="runtime-workspace" aria-label="Runtime workspace">
|
||||
<div className="runtime-workspace-loading">Loading runtime...</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="runtime-workspace" aria-label="Runtime workspace">
|
||||
{/* Header / Summary Section */}
|
||||
<div className="runtime-workspace-header">
|
||||
<div className="runtime-workspace-header-top">
|
||||
<div className="runtime-workspace-kicker">Runtime workspace</div>
|
||||
<span className={`runtime-workspace-mode runtime-workspace-mode-${workspaceSummary.modeLabel}`}>
|
||||
{workspaceSummary.modeLabel === "local" ? "Local" : "Remote"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="runtime-workspace-status">{workspaceSummary.statusLabel}</div>
|
||||
</div>
|
||||
|
||||
{/* Health Section */}
|
||||
<div className="runtime-workspace-health-section">
|
||||
<div className="runtime-workspace-section-label">Runtime health</div>
|
||||
<div className={`runtime-workspace-health ${healthToneClass}`}>
|
||||
<div className="runtime-workspace-health-headline">{workspaceHealth.headline}</div>
|
||||
{workspaceHealth.detail && <div className="runtime-workspace-health-detail">{workspaceHealth.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="runtime-workspace-details">
|
||||
<div className="runtime-workspace-detail-row">
|
||||
<span className="runtime-workspace-detail-label">Endpoint</span>
|
||||
<span className="runtime-workspace-detail-value">{workspaceSummary.endpointLabel || "Not configured"}</span>
|
||||
</div>
|
||||
<div className="runtime-workspace-detail-row">
|
||||
<span className="runtime-workspace-detail-label">Model</span>
|
||||
<span className="runtime-workspace-detail-value">{workspaceSummary.modelLabel || "No model selected"}</span>
|
||||
</div>
|
||||
{isLocal && (
|
||||
<div className="runtime-workspace-detail-row">
|
||||
<span className="runtime-workspace-detail-label">Start policy</span>
|
||||
<span className="runtime-workspace-detail-value">
|
||||
{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lifecycle Result Banner */}
|
||||
{showLifecycleResult && (
|
||||
<div className={`runtime-workspace-result ${lifecycleResultClass}`} role="status" aria-live="polite">
|
||||
{lifecycleResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="runtime-workspace-actions">
|
||||
<button className="runtime-workspace-btn runtime-workspace-btn-secondary" onClick={openDraft ?? (() => {})} type="button" disabled={lifecycleBusy}>
|
||||
{draftDirty ? "Edit draft" : "Manage"}
|
||||
</button>
|
||||
{isLocal && (
|
||||
<>
|
||||
<button
|
||||
className="runtime-workspace-btn runtime-workspace-btn-primary"
|
||||
onClick={snapshot.process_running ? (stopLocal ?? (() => {})) : (startLocal ?? (() => {}))}
|
||||
type="button"
|
||||
disabled={busy || lifecycleBusy || (snapshot.process_running && snapshot.status === "stopping")}
|
||||
>
|
||||
{lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Draft Settings (inline when open) */}
|
||||
{draftDirty && (
|
||||
<div className="runtime-workspace-draft-panel">
|
||||
<div className="runtime-workspace-draft-header">
|
||||
<span className="runtime-workspace-draft-title">Draft settings</span>
|
||||
<span className="runtime-workspace-draft-badge">Unsaved changes</span>
|
||||
</div>
|
||||
|
||||
{/* Apply Guidance */}
|
||||
{applyGuidance && (
|
||||
<div className={`runtime-workspace-apply-guidance${applyImpact !== "immediate" ? " is-warning" : ""}`}>
|
||||
{applyGuidance}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local mode draft fields */}
|
||||
{draftConfig.connection_mode === "local" && updateDraft && (
|
||||
<>
|
||||
<div className="runtime-workspace-draft-field">
|
||||
<label className="runtime-workspace-draft-label">Models folder</label>
|
||||
<input
|
||||
className="runtime-workspace-draft-input"
|
||||
value={draftConfig.models_dir || ""}
|
||||
onChange={(e) => updateDraft({ models_dir: e.target.value })}
|
||||
placeholder="D:/models"
|
||||
/>
|
||||
</div>
|
||||
<div className="runtime-workspace-draft-field">
|
||||
<label className="runtime-workspace-draft-label">Selected model</label>
|
||||
<input
|
||||
className="runtime-workspace-draft-input"
|
||||
value={draftConfig.model_path || ""}
|
||||
onChange={(e) => updateDraft({ model_path: e.target.value || null })}
|
||||
placeholder="D:/models/model.gguf"
|
||||
/>
|
||||
</div>
|
||||
<div className="runtime-workspace-draft-field">
|
||||
<label className="runtime-workspace-draft-label">GPU layers</label>
|
||||
<input
|
||||
className="runtime-workspace-draft-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draftConfig.gpu_layers}
|
||||
onChange={(e) => updateDraft({ gpu_layers: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remote mode draft fields */}
|
||||
{draftConfig.connection_mode === "remote" && updateDraft && (
|
||||
<>
|
||||
<div className="runtime-workspace-draft-field">
|
||||
<label className="runtime-workspace-draft-label">Server URL</label>
|
||||
<input
|
||||
className="runtime-workspace-draft-input"
|
||||
value={draftConfig.server_url}
|
||||
onChange={(e) => updateDraft({ server_url: e.target.value })}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="runtime-workspace-draft-field">
|
||||
<label className="runtime-workspace-draft-label">API key</label>
|
||||
<input
|
||||
className="runtime-workspace-draft-input"
|
||||
type="password"
|
||||
value={draftConfig.api_key}
|
||||
onChange={(e) => updateDraft({ api_key: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Draft Actions */}
|
||||
{updateDraft && resetDraft && (
|
||||
<div className="runtime-workspace-draft-actions">
|
||||
<button
|
||||
className="runtime-workspace-btn runtime-workspace-btn-secondary"
|
||||
onClick={resetDraft}
|
||||
type="button"
|
||||
disabled={busy || lifecycleBusy}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
className="runtime-workspace-btn runtime-workspace-btn-primary"
|
||||
onClick={() => { void saveConfig?.(draftConfig); }}
|
||||
type="button"
|
||||
disabled={busy || lifecycleBusy || hasBlockingDraftState}
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import {
|
||||
chooseModel,
|
||||
createRuntimeConfigPatch,
|
||||
dedupeModels,
|
||||
getLocalRuntimeStartReason,
|
||||
pathName,
|
||||
resolveDraftModelSelection,
|
||||
toModelFile,
|
||||
type RuntimeSettingsFormState,
|
||||
} from "./runtime-settings-form";
|
||||
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
|
||||
import {
|
||||
createRuntimeDraftState,
|
||||
RUNTIME_DRAFT_FIELD_KEYS,
|
||||
} from "./types";
|
||||
import type {
|
||||
RuntimeDraftFieldKey,
|
||||
RuntimeDraftFieldMeta,
|
||||
RuntimeDraftFieldState,
|
||||
RuntimeDraftState,
|
||||
RuntimeModelFile,
|
||||
} from "./types";
|
||||
|
||||
function createFormState(overrides: Partial<RuntimeSettingsFormState> = {}): RuntimeSettingsFormState {
|
||||
return {
|
||||
mode: "local",
|
||||
startPolicy: "manual",
|
||||
serverUrl: "https://api.example.com/",
|
||||
apiKey: " sk-demo ",
|
||||
modelsDir: "D:/models",
|
||||
selectedModel: "D:/models/demo.gguf",
|
||||
selectedModelId: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpuLayers: 48,
|
||||
ctxSize: 8192,
|
||||
extraModels: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runtime settings form helpers", () => {
|
||||
it("defines runtime draft ownership contracts separately from the live snapshot", () => {
|
||||
expect(RUNTIME_DRAFT_FIELD_KEYS).toEqual([
|
||||
"models_dir",
|
||||
"model_path",
|
||||
"selected_model_id",
|
||||
"server_url",
|
||||
"api_key",
|
||||
]);
|
||||
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
const draftState = createRuntimeDraftState(snapshot);
|
||||
|
||||
expect(draftState).toMatchObject({
|
||||
liveSnapshot: snapshot,
|
||||
savedBaseline: snapshot.config,
|
||||
draftConfig: snapshot.config,
|
||||
isDirty: false,
|
||||
isOpen: false,
|
||||
fields: {
|
||||
models_dir: { invalid: false, pending: false, message: null },
|
||||
model_path: { invalid: false, pending: false, message: null },
|
||||
selected_model_id: { invalid: false, pending: false, message: null },
|
||||
server_url: { invalid: false, pending: false, message: null },
|
||||
api_key: { invalid: false, pending: false, message: null },
|
||||
},
|
||||
});
|
||||
|
||||
expectTypeOf<RuntimeDraftFieldKey>().toEqualTypeOf<
|
||||
"models_dir" | "model_path" | "selected_model_id" | "server_url" | "api_key"
|
||||
>();
|
||||
|
||||
expectTypeOf<RuntimeDraftFieldMeta>().toMatchObjectType<{
|
||||
invalid: boolean;
|
||||
pending: boolean;
|
||||
message: string | null;
|
||||
}>();
|
||||
|
||||
expectTypeOf<RuntimeDraftFieldState>().toMatchTypeOf<
|
||||
Partial<Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta>>
|
||||
>();
|
||||
|
||||
expectTypeOf<RuntimeDraftState>().toMatchObjectType<{
|
||||
liveSnapshot: ReturnType<typeof createDefaultRuntimeSnapshot>;
|
||||
savedBaseline: ReturnType<typeof createDefaultRuntimeSnapshot>["config"];
|
||||
draftConfig: ReturnType<typeof createDefaultRuntimeSnapshot>["config"];
|
||||
isDirty: boolean;
|
||||
isOpen: boolean;
|
||||
fields: RuntimeDraftFieldState;
|
||||
scannedModels: RuntimeModelFile[];
|
||||
isScanning: boolean;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("keeps the current model when it still exists", () => {
|
||||
const current = "D:/models/demo.gguf";
|
||||
const scanned = [{ name: "demo.gguf", path: current }];
|
||||
const extra = [{ name: "other.gguf", path: "D:/models/other.gguf" }];
|
||||
|
||||
expect(chooseModel(current, scanned, extra)).toBe(current);
|
||||
});
|
||||
|
||||
it("falls back to the first available model when the current one disappears", () => {
|
||||
expect(
|
||||
chooseModel(
|
||||
"D:/models/missing.gguf",
|
||||
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
|
||||
[],
|
||||
),
|
||||
).toBe("D:/models/demo.gguf");
|
||||
});
|
||||
|
||||
it("keeps the drafted model during rescans when it is still valid", () => {
|
||||
expect(
|
||||
resolveDraftModelSelection(
|
||||
"D:/models/demo.gguf",
|
||||
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
|
||||
[],
|
||||
),
|
||||
).toEqual({
|
||||
nextModel: "D:/models/demo.gguf",
|
||||
invalid: false,
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an invalid drafted model visible instead of silently falling back", () => {
|
||||
expect(
|
||||
resolveDraftModelSelection(
|
||||
"D:/models/missing.gguf",
|
||||
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
|
||||
[],
|
||||
),
|
||||
).toEqual({
|
||||
nextModel: "D:/models/missing.gguf",
|
||||
invalid: true,
|
||||
message: "This draft model is no longer available in the current library or folder scan.",
|
||||
});
|
||||
});
|
||||
|
||||
it("chooses the first discovered model when there is no existing draft selection", () => {
|
||||
expect(
|
||||
resolveDraftModelSelection(
|
||||
"",
|
||||
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
|
||||
[],
|
||||
),
|
||||
).toEqual({
|
||||
nextModel: "D:/models/demo.gguf",
|
||||
invalid: false,
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates model entries by path while keeping first occurrence", () => {
|
||||
expect(
|
||||
dedupeModels([
|
||||
{ name: "demo.gguf", path: "D:/models/demo.gguf" },
|
||||
{ name: "duplicate.gguf", path: "D:/models/demo.gguf" },
|
||||
{ name: "other.gguf", path: "D:/models/other.gguf" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ name: "demo.gguf", path: "D:/models/demo.gguf" },
|
||||
{ name: "other.gguf", path: "D:/models/other.gguf" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds a trimmed local patch from the form state", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
const patch = createRuntimeConfigPatch(snapshot.config, createFormState(), 32);
|
||||
|
||||
expect(patch).toMatchObject({
|
||||
connection_mode: "local",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
selected_model_id: null,
|
||||
models_dir: "D:/models",
|
||||
gpu_layers: 32,
|
||||
ctx_size: 8192,
|
||||
extra_model_paths: ["D:/models/demo.gguf"],
|
||||
remote_presets: [],
|
||||
hugging_face_token: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps saved local values untouched when editing remote mode", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
const patch = createRuntimeConfigPatch(
|
||||
{
|
||||
...snapshot.config,
|
||||
model_path: "D:/models/keep.gguf",
|
||||
models_dir: "D:/models",
|
||||
extra_model_paths: ["D:/models/keep.gguf"],
|
||||
},
|
||||
createFormState({
|
||||
mode: "remote",
|
||||
serverUrl: " https://api.example.com/ ",
|
||||
apiKey: " remote-key ",
|
||||
modelsDir: "",
|
||||
selectedModel: "",
|
||||
extraModels: [],
|
||||
}),
|
||||
16,
|
||||
);
|
||||
|
||||
expect(patch).toMatchObject({
|
||||
connection_mode: "remote",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "remote-key",
|
||||
model_path: "D:/models/keep.gguf",
|
||||
selected_model_id: null,
|
||||
models_dir: "D:/models",
|
||||
extra_model_paths: ["D:/models/keep.gguf"],
|
||||
remote_presets: [],
|
||||
hugging_face_token: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves selected model id, presets, and token when building a patch", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
snapshot.config.selected_model_id = "managed:phi-4-mini";
|
||||
snapshot.config.remote_presets = [
|
||||
{
|
||||
id: "openrouter",
|
||||
label: "OpenRouter",
|
||||
provider: "open_router",
|
||||
base_url: "https://openrouter.ai/api/v1",
|
||||
default_model: "openrouter/auto",
|
||||
},
|
||||
];
|
||||
snapshot.config.hugging_face_token = "hf_demo";
|
||||
|
||||
const patch = createRuntimeConfigPatch(
|
||||
snapshot.config,
|
||||
{
|
||||
...createFormState(),
|
||||
selectedModelId: "managed:phi-4-mini",
|
||||
},
|
||||
64,
|
||||
);
|
||||
|
||||
expect(patch.selected_model_id).toBe("managed:phi-4-mini");
|
||||
expect(patch.remote_presets).toEqual(snapshot.config.remote_presets);
|
||||
expect(patch.hugging_face_token).toBe("hf_demo");
|
||||
});
|
||||
|
||||
it("writes the selected managed model id when the local selection comes from the model library", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
|
||||
const patch = createRuntimeConfigPatch(
|
||||
snapshot.config,
|
||||
{
|
||||
...createFormState(),
|
||||
selectedModel: "D:/managed/phi-4-mini.gguf",
|
||||
selectedModelId: "managed:phi-4-mini",
|
||||
},
|
||||
64,
|
||||
);
|
||||
|
||||
expect(patch.model_path).toBe("D:/managed/phi-4-mini.gguf");
|
||||
expect(patch.selected_model_id).toBe("managed:phi-4-mini");
|
||||
});
|
||||
|
||||
it("clears the selected managed model id when switching back to a manual path", () => {
|
||||
const snapshot = createDefaultRuntimeSnapshot();
|
||||
snapshot.config.selected_model_id = "managed:phi-4-mini";
|
||||
|
||||
const patch = createRuntimeConfigPatch(
|
||||
snapshot.config,
|
||||
{
|
||||
...createFormState(),
|
||||
selectedModel: "D:/models/manual.gguf",
|
||||
selectedModelId: null,
|
||||
},
|
||||
64,
|
||||
);
|
||||
|
||||
expect(patch.model_path).toBe("D:/models/manual.gguf");
|
||||
expect(patch.selected_model_id).toBeNull();
|
||||
});
|
||||
|
||||
it("explains why local runtime start is blocked", () => {
|
||||
expect(
|
||||
getLocalRuntimeStartReason({ mode: "local", binaryPath: null, selectedModel: "D:/models/demo.gguf" }),
|
||||
).toContain("Build or bundle");
|
||||
|
||||
expect(
|
||||
getLocalRuntimeStartReason({ mode: "local", binaryPath: "D:/bin/localllm.exe", selectedModel: "" }),
|
||||
).toContain("Choose a model");
|
||||
|
||||
expect(
|
||||
getLocalRuntimeStartReason({
|
||||
mode: "local",
|
||||
binaryPath: "D:/bin/localllm.exe",
|
||||
selectedModel: "D:/models/demo.gguf",
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes model file labels from a path", () => {
|
||||
expect(pathName("D:/models/demo.gguf")).toBe("demo.gguf");
|
||||
expect(toModelFile("D:/models/demo.gguf")).toEqual({
|
||||
name: "demo.gguf",
|
||||
path: "D:/models/demo.gguf",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type {
|
||||
ConnectionMode,
|
||||
RuntimeConfig,
|
||||
RuntimeModelFile,
|
||||
StartPolicy,
|
||||
} from "./types";
|
||||
|
||||
function normalizeUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export interface RuntimeSettingsFormState {
|
||||
mode: ConnectionMode;
|
||||
startPolicy: StartPolicy;
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
modelsDir: string;
|
||||
selectedModel: string;
|
||||
selectedModelId: string | null;
|
||||
addr: string;
|
||||
gpuLayers: number;
|
||||
ctxSize: number;
|
||||
extraModels: RuntimeModelFile[];
|
||||
}
|
||||
|
||||
export function pathName(path: string | null | undefined): string {
|
||||
if (!path) return "";
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
export function toModelFile(path: string): RuntimeModelFile {
|
||||
return { name: pathName(path) || path, path };
|
||||
}
|
||||
|
||||
export function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] {
|
||||
const seen = new Set<string>();
|
||||
return models.filter((model) => {
|
||||
if (seen.has(model.path)) return false;
|
||||
seen.add(model.path);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string {
|
||||
const nextChoices = [...scanned, ...extra].map((model) => model.path);
|
||||
if (current && (nextChoices.length === 0 || nextChoices.includes(current))) {
|
||||
return current;
|
||||
}
|
||||
return scanned[0]?.path || extra[0]?.path || "";
|
||||
}
|
||||
|
||||
export interface RuntimeDraftModelSelection {
|
||||
nextModel: string;
|
||||
invalid: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export function resolveDraftModelSelection(
|
||||
current: string,
|
||||
scanned: RuntimeModelFile[],
|
||||
extra: RuntimeModelFile[],
|
||||
): RuntimeDraftModelSelection {
|
||||
const nextChoices = [...scanned, ...extra].map((model) => model.path);
|
||||
|
||||
if (!current) {
|
||||
return {
|
||||
nextModel: scanned[0]?.path || extra[0]?.path || "",
|
||||
invalid: false,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (nextChoices.length === 0 || nextChoices.includes(current)) {
|
||||
return {
|
||||
nextModel: current,
|
||||
invalid: false,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nextModel: current,
|
||||
invalid: true,
|
||||
message: "This draft model is no longer available in the current library or folder scan.",
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalRuntimeStartReason(params: {
|
||||
mode: ConnectionMode;
|
||||
binaryPath: string | null;
|
||||
selectedModel: string;
|
||||
}): string {
|
||||
const { mode, binaryPath, selectedModel } = params;
|
||||
|
||||
if (mode !== "local") {
|
||||
return "Save mode before starting the local runtime.";
|
||||
}
|
||||
|
||||
if (!binaryPath) {
|
||||
return "Build or bundle the local API binary before starting.";
|
||||
}
|
||||
|
||||
if (!selectedModel.trim()) {
|
||||
return "Choose a model before starting the local runtime.";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function createRuntimeConfigPatch(
|
||||
snapshotConfig: RuntimeConfig,
|
||||
form: RuntimeSettingsFormState,
|
||||
maxLayers: number,
|
||||
): RuntimeConfig {
|
||||
return {
|
||||
connection_mode: form.mode,
|
||||
start_policy: form.startPolicy,
|
||||
server_url: normalizeUrl(form.serverUrl),
|
||||
api_key: form.apiKey.trim(),
|
||||
models_dir: form.mode === "local" ? (form.modelsDir.trim() || null) : snapshotConfig.models_dir,
|
||||
model_path: form.mode === "local" ? (form.selectedModel.trim() || null) : snapshotConfig.model_path,
|
||||
addr: form.mode === "local" ? form.addr.trim() || snapshotConfig.addr : snapshotConfig.addr,
|
||||
gpu_layers:
|
||||
form.mode === "local"
|
||||
? Math.max(0, Math.min(form.gpuLayers, maxLayers))
|
||||
: snapshotConfig.gpu_layers,
|
||||
ctx_size: form.mode === "local" ? Math.max(512, form.ctxSize || 4096) : snapshotConfig.ctx_size,
|
||||
selected_model_id: form.mode === "local" ? form.selectedModelId ?? null : snapshotConfig.selected_model_id ?? null,
|
||||
extra_model_paths:
|
||||
form.mode === "local"
|
||||
? form.extraModels.map((model) => model.path)
|
||||
: snapshotConfig.extra_model_paths,
|
||||
remote_presets: snapshotConfig.remote_presets ?? [],
|
||||
hugging_face_token: snapshotConfig.hugging_face_token ?? "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRuntimeLifecycleResult, getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
|
||||
|
||||
type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & {
|
||||
config?: Partial<RuntimeConfig>;
|
||||
};
|
||||
|
||||
function createSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
|
||||
const { config: configOverrides, ...snapshotOverrides } = overrides;
|
||||
const defaultConfig: RuntimeConfig = {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
selected_model_id: null,
|
||||
remote_presets: [],
|
||||
hugging_face_token: "",
|
||||
};
|
||||
|
||||
return {
|
||||
status: "stopped",
|
||||
ready: false,
|
||||
process_running: false,
|
||||
resolved_url: "http://127.0.0.1:8080",
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
active_command: "idle",
|
||||
last_command_result: createRuntimeLifecycleResult(),
|
||||
config: {
|
||||
...defaultConfig,
|
||||
...(configOverrides ?? {}),
|
||||
},
|
||||
...snapshotOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getRuntimeConnection", () => {
|
||||
it("prefers the resolved local runtime url in local mode", () => {
|
||||
const connection = getRuntimeConnection(createSnapshot({
|
||||
resolved_url: "http://127.0.0.1:9000",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "http://example.com:9999",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:9000",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(connection.serverUrl).toBe("http://127.0.0.1:9000");
|
||||
expect(connection.mode).toBe("local");
|
||||
});
|
||||
|
||||
it("uses remote settings in remote mode", () => {
|
||||
const connection = getRuntimeConnection(createSnapshot({
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(connection.serverUrl).toBe("https://api.example.com");
|
||||
expect(connection.apiKey).toBe("sk-demo");
|
||||
expect(connection.mode).toBe("remote");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStateLabel", () => {
|
||||
it("reports ready snapshots as ready", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
|
||||
});
|
||||
|
||||
it("reports local background start progress", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
|
||||
});
|
||||
|
||||
it("reports remote mode without runtime ownership", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({
|
||||
status: "stopped",
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}))).toBe("Remote API");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStartGuard", () => {
|
||||
it("requires a saved model before starting the local runtime", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(false);
|
||||
expect(guard.reason.toLowerCase()).toContain("model");
|
||||
});
|
||||
|
||||
it("allows starting when the saved local runtime config is complete", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
models_dir: "D:/models",
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 24,
|
||||
ctx_size: 8192,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(true);
|
||||
expect(guard.reason).toBe("");
|
||||
});
|
||||
|
||||
it("never offers managed start controls in remote mode", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(false);
|
||||
expect(guard.reason.toLowerCase()).toContain("remote");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import type {
|
||||
ConnectionMode,
|
||||
RuntimeApplyImpact,
|
||||
RuntimeConfig,
|
||||
RuntimeConnection,
|
||||
RuntimeDraftFieldKey,
|
||||
RuntimeLifecycleCommand,
|
||||
RuntimeLifecycleResult,
|
||||
RuntimeModelFile,
|
||||
RuntimeSnapshot,
|
||||
RuntimeWorkspaceHealth,
|
||||
RuntimeWorkspaceHealthTone,
|
||||
RuntimeWorkspaceSummary,
|
||||
} from "./types";
|
||||
import { scanRuntimeModels } from "./api";
|
||||
|
||||
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
|
||||
|
||||
// === Phase 4: Source-Mode Validation Types ===
|
||||
|
||||
export type ValidationSeverity = "error" | "warning";
|
||||
|
||||
export interface FieldValidation {
|
||||
valid: boolean;
|
||||
severity: ValidationSeverity;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface SourceModeValidation {
|
||||
mode: ConnectionMode;
|
||||
valid: boolean;
|
||||
fieldValidations: Record<string, FieldValidation>;
|
||||
}
|
||||
|
||||
export interface PathValidation {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
isValid: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
// === Phase 4: Source-Mode Validation Functions ===
|
||||
|
||||
/**
|
||||
* Validates source-mode configuration based on connection mode.
|
||||
* For local mode, requires model_path. For remote mode, requires server_url.
|
||||
*/
|
||||
export function validateSourceMode(config: RuntimeConfig): SourceModeValidation {
|
||||
const fieldValidations: Record<string, FieldValidation> = {};
|
||||
let valid = true;
|
||||
|
||||
if (config.connection_mode === "local") {
|
||||
// Validate local mode requirements
|
||||
if (!config.model_path) {
|
||||
fieldValidations.model_path = {
|
||||
valid: false,
|
||||
severity: "error",
|
||||
message: "Select a model file for local runtime",
|
||||
};
|
||||
valid = false;
|
||||
}
|
||||
// models_dir is optional for local mode
|
||||
} else if (config.connection_mode === "remote") {
|
||||
// Validate remote mode requirements
|
||||
if (!config.server_url) {
|
||||
fieldValidations.server_url = {
|
||||
valid: false,
|
||||
severity: "error",
|
||||
message: "Server URL is required for remote runtime",
|
||||
};
|
||||
valid = false;
|
||||
}
|
||||
|
||||
// Validate URL format for remote mode
|
||||
if (config.server_url) {
|
||||
try {
|
||||
const url = new URL(config.server_url);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
fieldValidations.server_url = {
|
||||
valid: false,
|
||||
severity: "error",
|
||||
message: "Server URL must use http or https protocol",
|
||||
};
|
||||
valid = false;
|
||||
}
|
||||
} catch {
|
||||
fieldValidations.server_url = {
|
||||
valid: false,
|
||||
severity: "error",
|
||||
message: "Invalid URL format",
|
||||
};
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: config.connection_mode,
|
||||
valid,
|
||||
fieldValidations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a model path. For now, returns pending status.
|
||||
* Actual existence check happens via scan in plan 04-02.
|
||||
*/
|
||||
export function validateModelPath(path: string | null): PathValidation {
|
||||
if (!path) {
|
||||
return { path: "", exists: false, isValid: false, message: null };
|
||||
}
|
||||
// For now, return valid - actual existence check happens via scan
|
||||
return { path, exists: true, isValid: true, message: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field keys that require validation based on connection mode
|
||||
*/
|
||||
export function getValidatedFields(mode: ConnectionMode): RuntimeDraftFieldKey[] {
|
||||
if (mode === "local") {
|
||||
return ["model_path", "models_dir"];
|
||||
}
|
||||
return ["server_url", "api_key"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if draft has any validation errors
|
||||
*/
|
||||
export function hasValidationErrors(fieldValidations: Record<string, FieldValidation>): boolean {
|
||||
return Object.values(fieldValidations).some((fv) => !fv.valid);
|
||||
}
|
||||
|
||||
// === Phase 4: Path and Model File Validation (Async) ===
|
||||
|
||||
export interface DirectoryValidation {
|
||||
valid: boolean;
|
||||
modelCount: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ModelFileValidation {
|
||||
valid: boolean;
|
||||
exists: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
const RESTART_REQUIRED_FIELDS: Array<keyof RuntimeConfig> = [
|
||||
"connection_mode",
|
||||
"model_path",
|
||||
"addr",
|
||||
"gpu_layers",
|
||||
"ctx_size",
|
||||
];
|
||||
|
||||
function normalizeUrl(value: string | null | undefined): string {
|
||||
const trimmed = (value || "").trim();
|
||||
return trimmed ? trimmed.replace(/\/+$/, "") : "";
|
||||
}
|
||||
|
||||
export function getRuntimeConnection(snapshot: RuntimeSnapshot): RuntimeConnection {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return {
|
||||
mode: "remote",
|
||||
serverUrl: normalizeUrl(snapshot.config.server_url),
|
||||
apiKey: snapshot.config.api_key || "",
|
||||
managed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "local",
|
||||
serverUrl: normalizeUrl(snapshot.resolved_url) || DEFAULT_LOCAL_URL,
|
||||
apiKey: snapshot.config.api_key || "",
|
||||
managed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return "Remote API";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "Ready";
|
||||
case "starting":
|
||||
return "Starting local API";
|
||||
case "stopping":
|
||||
return "Stopping local API";
|
||||
case "error":
|
||||
return "Local API error";
|
||||
case "degraded":
|
||||
return "Attention needed";
|
||||
default:
|
||||
return "Local API offline";
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "success" | "warning" | "danger" {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "success";
|
||||
case "starting":
|
||||
case "stopping":
|
||||
case "degraded":
|
||||
return "warning";
|
||||
case "error":
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuntimeStartGuard {
|
||||
canStart: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RuntimeApplyImpactOptions {
|
||||
savedBaseline: RuntimeConfig;
|
||||
draftConfig: RuntimeConfig;
|
||||
processRunning: boolean;
|
||||
}
|
||||
|
||||
export function createRuntimeLifecycleResult(
|
||||
command: RuntimeLifecycleCommand = "idle",
|
||||
message = "Runtime idle.",
|
||||
outcome: RuntimeLifecycleResult["outcome"] = "succeeded",
|
||||
): RuntimeLifecycleResult {
|
||||
return {
|
||||
command,
|
||||
outcome,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeApplyImpact({
|
||||
savedBaseline,
|
||||
draftConfig,
|
||||
processRunning,
|
||||
}: RuntimeApplyImpactOptions): RuntimeApplyImpact {
|
||||
if (savedBaseline.connection_mode !== draftConfig.connection_mode) {
|
||||
return "save_before_apply";
|
||||
}
|
||||
|
||||
const needsRestart = RESTART_REQUIRED_FIELDS.some((field) => savedBaseline[field] !== draftConfig[field]);
|
||||
|
||||
if (processRunning && needsRestart) {
|
||||
return "restart_required";
|
||||
}
|
||||
|
||||
return "immediate";
|
||||
}
|
||||
|
||||
export function getRuntimeStartGuard(snapshot: RuntimeSnapshot): RuntimeStartGuard {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Remote mode uses an external API and does not launch the local runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snapshot.binary_path) {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Build or bundle the local API binary before starting the managed runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snapshot.config.model_path?.trim()) {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Choose a local model before starting the managed runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
return { canStart: true, reason: "" };
|
||||
}
|
||||
|
||||
export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
|
||||
return {
|
||||
status: "stopped",
|
||||
ready: false,
|
||||
process_running: false,
|
||||
resolved_url: DEFAULT_LOCAL_URL,
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
active_command: "idle",
|
||||
last_command_result: createRuntimeLifecycleResult(),
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
selected_model_id: null,
|
||||
remote_presets: [],
|
||||
hugging_face_token: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// === Phase 3: Unified Runtime Workspace Helpers ===
|
||||
|
||||
function getModelLabel(modelPath: string | null): string | null {
|
||||
if (!modelPath) return null;
|
||||
const parts = modelPath.replace(/\\/g, "/").split("/");
|
||||
return parts[parts.length - 1] || null;
|
||||
}
|
||||
|
||||
export function getRuntimeWorkspaceSummary(snapshot: RuntimeSnapshot): RuntimeWorkspaceSummary {
|
||||
const connection = getRuntimeConnection(snapshot);
|
||||
const statusLabel = getRuntimeStateLabel(snapshot);
|
||||
const isRemote = snapshot.config.connection_mode === "remote";
|
||||
|
||||
return {
|
||||
statusLabel,
|
||||
modeLabel: isRemote ? "remote" : "local",
|
||||
endpointLabel: connection.serverUrl,
|
||||
modelLabel: getModelLabel(snapshot.config.model_path),
|
||||
isReady: snapshot.ready,
|
||||
isRunning: snapshot.process_running,
|
||||
hasError: snapshot.status === "error" || (snapshot.last_error !== null && snapshot.last_error.code !== "poll_warning"),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveHealthTone(snapshot: RuntimeSnapshot): RuntimeWorkspaceHealthTone {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "success";
|
||||
case "starting":
|
||||
case "stopping":
|
||||
case "degraded":
|
||||
return "warning";
|
||||
case "error":
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
function deriveHealthHeadline(snapshot: RuntimeSnapshot): string {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return "Remote API connected";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "Local API ready";
|
||||
case "starting":
|
||||
return "Starting local API...";
|
||||
case "stopping":
|
||||
return "Stopping local API...";
|
||||
case "degraded":
|
||||
return "Attention needed";
|
||||
case "error":
|
||||
return "Local API error";
|
||||
default:
|
||||
return "Local API offline";
|
||||
}
|
||||
}
|
||||
|
||||
function deriveHealthDetail(snapshot: RuntimeSnapshot): string {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return snapshot.config.server_url || "Configure remote endpoint";
|
||||
}
|
||||
|
||||
if (snapshot.last_error) {
|
||||
return snapshot.last_error.message;
|
||||
}
|
||||
|
||||
if (snapshot.status === "ready" && snapshot.process_running) {
|
||||
return snapshot.resolved_url || "http://127.0.0.1:8080";
|
||||
}
|
||||
|
||||
if (snapshot.status === "stopped") {
|
||||
return "Start the local API to begin";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getRuntimeHealthSummary(snapshot: RuntimeSnapshot): RuntimeWorkspaceHealth {
|
||||
return {
|
||||
headline: deriveHealthHeadline(snapshot),
|
||||
detail: deriveHealthDetail(snapshot),
|
||||
tone: deriveHealthTone(snapshot),
|
||||
};
|
||||
}
|
||||
|
||||
// === Phase 4: Async Path and Model Validation ===
|
||||
|
||||
/**
|
||||
* Validate models directory by scanning it
|
||||
*/
|
||||
export async function validateModelsDirectory(
|
||||
path: string | null,
|
||||
): Promise<DirectoryValidation> {
|
||||
if (!path) {
|
||||
return { valid: false, modelCount: 0, error: "No models directory selected" };
|
||||
}
|
||||
try {
|
||||
const models = await scanRuntimeModels(path);
|
||||
return {
|
||||
valid: true,
|
||||
modelCount: models.length,
|
||||
error: null,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
modelCount: 0,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a model path exists in the scanned models list
|
||||
*/
|
||||
export function validateModelFile(
|
||||
path: string | null,
|
||||
scannedModels: RuntimeModelFile[],
|
||||
): ModelFileValidation {
|
||||
if (!path) {
|
||||
return { valid: false, exists: false, error: "No model selected" };
|
||||
}
|
||||
const exists = scannedModels.some((m) => m.path === path);
|
||||
return {
|
||||
valid: exists,
|
||||
exists,
|
||||
error: exists ? null : "Model file not found in current models directory",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
export type ConnectionMode = "local" | "remote";
|
||||
export type StartPolicy = "manual" | "on_app_launch";
|
||||
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
|
||||
export type RuntimeLifecycleCommand = "idle" | "save" | "start" | "stop" | "save_and_start";
|
||||
export type RuntimeLifecycleOutcome = "succeeded" | "blocked" | "failed";
|
||||
export type RuntimeApplyImpact = "immediate" | "restart_required" | "save_before_apply";
|
||||
export type RemoteProviderKind = "open_ai" | "open_router" | "anthropic" | "custom" | "unknown";
|
||||
|
||||
export interface RemotePreset {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: RemoteProviderKind;
|
||||
base_url: string;
|
||||
default_model: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
connection_mode: ConnectionMode;
|
||||
start_policy: StartPolicy;
|
||||
server_url: string;
|
||||
api_key: string;
|
||||
model_path: string | null;
|
||||
models_dir: string | null;
|
||||
addr: string;
|
||||
gpu_layers: number;
|
||||
ctx_size: number;
|
||||
extra_model_paths: string[];
|
||||
selected_model_id: string | null;
|
||||
remote_presets: RemotePreset[];
|
||||
hugging_face_token: string;
|
||||
}
|
||||
|
||||
export interface RuntimeErrorInfo {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RuntimeLifecycleResult {
|
||||
command: RuntimeLifecycleCommand;
|
||||
outcome: RuntimeLifecycleOutcome;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSnapshot {
|
||||
status: RuntimeStatus;
|
||||
ready: boolean;
|
||||
process_running: boolean;
|
||||
resolved_url: string;
|
||||
binary_path: string | null;
|
||||
last_error: RuntimeErrorInfo | null;
|
||||
active_command: RuntimeLifecycleCommand;
|
||||
last_command_result: RuntimeLifecycleResult;
|
||||
config: RuntimeConfig;
|
||||
}
|
||||
|
||||
export const RUNTIME_DRAFT_FIELD_KEYS = [
|
||||
"models_dir",
|
||||
"model_path",
|
||||
"selected_model_id",
|
||||
"server_url",
|
||||
"api_key",
|
||||
] as const;
|
||||
|
||||
export type RuntimeDraftFieldKey = (typeof RUNTIME_DRAFT_FIELD_KEYS)[number];
|
||||
|
||||
export interface RuntimeDraftFieldMeta {
|
||||
invalid: boolean;
|
||||
pending: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export type RuntimeDraftFieldState = Partial<Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta>>;
|
||||
|
||||
export interface RuntimeDraftState {
|
||||
liveSnapshot: RuntimeSnapshot;
|
||||
savedBaseline: RuntimeConfig;
|
||||
draftConfig: RuntimeConfig;
|
||||
isDirty: boolean;
|
||||
isOpen: boolean;
|
||||
fields: RuntimeDraftFieldState;
|
||||
// Phase 4: Scanned models for validation
|
||||
scannedModels: RuntimeModelFile[];
|
||||
isScanning: boolean;
|
||||
}
|
||||
|
||||
export function createRuntimeDraftFieldState(): Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta> {
|
||||
return {
|
||||
models_dir: { invalid: false, pending: false, message: null },
|
||||
model_path: { invalid: false, pending: false, message: null },
|
||||
selected_model_id: { invalid: false, pending: false, message: null },
|
||||
server_url: { invalid: false, pending: false, message: null },
|
||||
api_key: { invalid: false, pending: false, message: null },
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeDraftState(snapshot: RuntimeSnapshot): RuntimeDraftState {
|
||||
return {
|
||||
liveSnapshot: snapshot,
|
||||
savedBaseline: snapshot.config,
|
||||
draftConfig: snapshot.config,
|
||||
isDirty: false,
|
||||
isOpen: false,
|
||||
fields: createRuntimeDraftFieldState(),
|
||||
// Phase 4: Scanned models for validation
|
||||
scannedModels: [],
|
||||
isScanning: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeConnection {
|
||||
mode: ConnectionMode;
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeModelFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RuntimePickDirResult {
|
||||
dir: string;
|
||||
models: RuntimeModelFile[];
|
||||
}
|
||||
|
||||
export interface RuntimeGpuInfo {
|
||||
name: string;
|
||||
vram_mib: number;
|
||||
recommended_layers: number;
|
||||
}
|
||||
|
||||
export interface HuggingFaceVerifiedFile {
|
||||
path: string;
|
||||
size: number | null;
|
||||
quantization: string | null;
|
||||
}
|
||||
|
||||
export interface HuggingFaceModelResult {
|
||||
repo_id: string;
|
||||
author: string;
|
||||
verified_files: HuggingFaceVerifiedFile[];
|
||||
}
|
||||
|
||||
export interface ModelLibraryEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
source: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ModelLibraryState {
|
||||
entries: ModelLibraryEntry[];
|
||||
managed_dir: string;
|
||||
}
|
||||
|
||||
// === Phase 3: Unified Runtime Workspace Types ===
|
||||
|
||||
export type RuntimeWorkspaceHealthTone = "neutral" | "success" | "warning" | "danger";
|
||||
|
||||
export interface RuntimeWorkspaceSectionValue {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface RuntimeWorkspaceHealth {
|
||||
headline: string;
|
||||
detail: string;
|
||||
tone: RuntimeWorkspaceHealthTone;
|
||||
}
|
||||
|
||||
export interface RuntimeWorkspaceSummary {
|
||||
statusLabel: string;
|
||||
modeLabel: "local" | "remote";
|
||||
endpointLabel: string;
|
||||
modelLabel: string | null;
|
||||
isReady: boolean;
|
||||
isRunning: boolean;
|
||||
hasError: boolean;
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, scanRuntimeModels, startRuntime, stopRuntime } from "./api";
|
||||
import {
|
||||
createDefaultRuntimeSnapshot,
|
||||
createRuntimeLifecycleResult,
|
||||
getRuntimeApplyImpact,
|
||||
getRuntimeConnection,
|
||||
getRuntimeHealthSummary,
|
||||
getRuntimeWorkspaceSummary,
|
||||
validateModelFile,
|
||||
validateModelsDirectory,
|
||||
validateSourceMode,
|
||||
} from "./runtime-utils";
|
||||
import { createRuntimeDraftFieldState, createRuntimeDraftState } from "./types";
|
||||
import type {
|
||||
RuntimeApplyImpact,
|
||||
RuntimeConfig,
|
||||
RuntimeDraftFieldKey,
|
||||
RuntimeDraftFieldMeta,
|
||||
RuntimeDraftState,
|
||||
RuntimeLifecycleCommand,
|
||||
RuntimeLifecycleResult,
|
||||
RuntimeModelFile,
|
||||
RuntimeSnapshot,
|
||||
RuntimeWorkspaceHealth,
|
||||
RuntimeWorkspaceSummary,
|
||||
} from "./types";
|
||||
|
||||
function areConfigsEqual(left: RuntimeConfig, right: RuntimeConfig): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function cloneConfig(config: RuntimeConfig): RuntimeConfig {
|
||||
return {
|
||||
...config,
|
||||
extra_model_paths: [...config.extra_model_paths],
|
||||
remote_presets: config.remote_presets.map((preset) => ({ ...preset })),
|
||||
};
|
||||
}
|
||||
|
||||
function applyOperationalSnapshot(current: RuntimeSnapshot, next: RuntimeSnapshot): RuntimeSnapshot {
|
||||
return {
|
||||
...current,
|
||||
status: next.status,
|
||||
ready: next.ready,
|
||||
process_running: next.process_running,
|
||||
resolved_url: next.resolved_url,
|
||||
binary_path: next.binary_path,
|
||||
last_error: next.last_error,
|
||||
active_command: next.active_command,
|
||||
last_command_result: next.last_command_result,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
|
||||
return {
|
||||
...createDefaultRuntimeSnapshot(),
|
||||
...snapshot,
|
||||
config: {
|
||||
...createDefaultRuntimeSnapshot().config,
|
||||
...snapshot.config,
|
||||
},
|
||||
last_command_result:
|
||||
snapshot.last_command_result ?? createRuntimeLifecycleResult(snapshot.active_command ?? "idle"),
|
||||
active_command: snapshot.active_command ?? "idle",
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileDraftState(current: RuntimeDraftState, next: RuntimeSnapshot): RuntimeDraftState {
|
||||
const normalizedNext = normalizeSnapshot(next);
|
||||
|
||||
if (current.isDirty) {
|
||||
return {
|
||||
...current,
|
||||
liveSnapshot: applyOperationalSnapshot(current.liveSnapshot, normalizedNext),
|
||||
};
|
||||
}
|
||||
|
||||
// Run source-mode validation when reconciling to baseline
|
||||
const validation = validateSourceMode(normalizedNext.config);
|
||||
const updatedFields = createRuntimeDraftFieldState();
|
||||
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
|
||||
updatedFields[fieldName as RuntimeDraftFieldKey] = {
|
||||
invalid: !fieldValidation.valid,
|
||||
pending: false,
|
||||
message: fieldValidation.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
liveSnapshot: normalizedNext,
|
||||
savedBaseline: cloneConfig(normalizedNext.config),
|
||||
draftConfig: cloneConfig(normalizedNext.config),
|
||||
fields: updatedFields,
|
||||
// Preserve scanned models state during reconciliation
|
||||
scannedModels: current.scannedModels,
|
||||
isScanning: current.isScanning,
|
||||
};
|
||||
}
|
||||
|
||||
function blockWhileLifecycleBusy(
|
||||
snapshot: RuntimeSnapshot,
|
||||
command: RuntimeLifecycleCommand,
|
||||
): RuntimeSnapshot | null {
|
||||
if (snapshot.active_command === "idle") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockedBy = snapshot.active_command;
|
||||
const message =
|
||||
blockedBy === "start"
|
||||
? "Local API start already in progress."
|
||||
: blockedBy === "stop"
|
||||
? "Local API stop already in progress."
|
||||
: blockedBy === "save"
|
||||
? "Runtime settings save already in progress."
|
||||
: "Runtime save and start already in progress.";
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
last_command_result: createRuntimeLifecycleResult(command, message, "blocked"),
|
||||
};
|
||||
}
|
||||
|
||||
function startLifecycleSnapshot(
|
||||
snapshot: RuntimeSnapshot,
|
||||
command: RuntimeLifecycleCommand,
|
||||
status: RuntimeSnapshot["status"],
|
||||
): RuntimeSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
status,
|
||||
process_running: command === "start" ? true : snapshot.process_running,
|
||||
ready: false,
|
||||
last_error: null,
|
||||
active_command: command,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeLifecycleSnapshot(
|
||||
fallbackCommand: RuntimeLifecycleCommand,
|
||||
fallbackMessage: string,
|
||||
next: RuntimeSnapshot,
|
||||
): RuntimeSnapshot {
|
||||
const normalizedNext = normalizeSnapshot(next);
|
||||
if (normalizedNext.last_command_result.command !== "idle") {
|
||||
return normalizedNext;
|
||||
}
|
||||
|
||||
return {
|
||||
...normalizedNext,
|
||||
last_command_result: createRuntimeLifecycleResult(fallbackCommand, fallbackMessage),
|
||||
};
|
||||
}
|
||||
|
||||
export function useRuntimeManager() {
|
||||
const [draftState, setDraftState] = useState<RuntimeDraftState>(() =>
|
||||
createRuntimeDraftState(createDefaultRuntimeSnapshot()),
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const next = await getRuntimeSnapshot();
|
||||
setDraftState((current) => reconcileDraftState(current, next));
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
bootstrapRuntime()
|
||||
.then((next) => {
|
||||
if (active) {
|
||||
setDraftState((current) => reconcileDraftState(current, next));
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
refresh().catch(() => undefined);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const openDraft = useCallback(() => {
|
||||
setDraftState((current) => {
|
||||
const draftConfig = current.isDirty ? current.draftConfig : cloneConfig(current.savedBaseline);
|
||||
|
||||
// Run source-mode validation when opening draft
|
||||
const validation = validateSourceMode(draftConfig);
|
||||
|
||||
// Initialize field validations based on validation results
|
||||
const updatedFields = { ...current.fields };
|
||||
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
|
||||
updatedFields[fieldName as RuntimeDraftFieldKey] = {
|
||||
invalid: !fieldValidation.valid,
|
||||
pending: false,
|
||||
message: fieldValidation.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
isOpen: true,
|
||||
draftConfig,
|
||||
fields: updatedFields,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeDraft = useCallback(() => {
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
isOpen: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetDraft = useCallback(() => {
|
||||
setDraftState((current) => {
|
||||
const resetConfig = cloneConfig(current.savedBaseline);
|
||||
|
||||
// Run source-mode validation when resetting
|
||||
const validation = validateSourceMode(resetConfig);
|
||||
|
||||
// Initialize field validations based on validation results
|
||||
const updatedFields = createRuntimeDraftFieldState();
|
||||
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
|
||||
updatedFields[fieldName as RuntimeDraftFieldKey] = {
|
||||
invalid: !fieldValidation.valid,
|
||||
pending: false,
|
||||
message: fieldValidation.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
draftConfig: resetConfig,
|
||||
isDirty: false,
|
||||
fields: updatedFields,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback((patch: Partial<RuntimeConfig>) => {
|
||||
setDraftState((current) => {
|
||||
const nextDraft = {
|
||||
...current.draftConfig,
|
||||
...patch,
|
||||
};
|
||||
|
||||
return {
|
||||
...current,
|
||||
isOpen: true,
|
||||
isDirty: !areConfigsEqual(nextDraft, current.savedBaseline),
|
||||
draftConfig: nextDraft,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateDraftField = useCallback(<K extends RuntimeDraftFieldKey>(field: K, value: RuntimeConfig[K]) => {
|
||||
setDraftState((current) => {
|
||||
const nextDraft = {
|
||||
...current.draftConfig,
|
||||
[field]: value,
|
||||
};
|
||||
|
||||
// Run source-mode validation
|
||||
const validation = validateSourceMode(nextDraft);
|
||||
|
||||
// Update field validations based on validation results
|
||||
const updatedFields = { ...current.fields };
|
||||
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
|
||||
updatedFields[fieldName as RuntimeDraftFieldKey] = {
|
||||
invalid: !fieldValidation.valid,
|
||||
pending: false,
|
||||
message: fieldValidation.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
isOpen: true,
|
||||
isDirty: !areConfigsEqual(nextDraft, current.savedBaseline),
|
||||
draftConfig: nextDraft,
|
||||
fields: updatedFields,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateDraftFieldMeta = useCallback(
|
||||
(field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => {
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
fields: {
|
||||
...current.fields,
|
||||
[field]: {
|
||||
...current.fields[field],
|
||||
invalid: false,
|
||||
pending: false,
|
||||
message: null,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Phase 4: Model scanning for validation
|
||||
const scanModels = useCallback(async (dirPath: string | null) => {
|
||||
if (!dirPath) {
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
scannedModels: [],
|
||||
isScanning: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set scanning state first
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
isScanning: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const models = await scanRuntimeModels(dirPath);
|
||||
|
||||
// Now update with results
|
||||
setDraftState((current) => {
|
||||
const modelPathInvalid = current.draftConfig.model_path
|
||||
? !models.some((m) => m.path === current.draftConfig.model_path)
|
||||
: false;
|
||||
const modelPathMessage = current.draftConfig.model_path
|
||||
? models.some((m) => m.path === current.draftConfig.model_path)
|
||||
? null
|
||||
: "Model file not found in current models directory"
|
||||
: null;
|
||||
|
||||
return {
|
||||
...current,
|
||||
scannedModels: models,
|
||||
isScanning: false,
|
||||
// Validate model_path against scanned models
|
||||
fields: {
|
||||
...current.fields,
|
||||
model_path: {
|
||||
invalid: modelPathInvalid,
|
||||
pending: false,
|
||||
message: modelPathMessage,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
scannedModels: [],
|
||||
isScanning: false,
|
||||
fields: {
|
||||
...current.fields,
|
||||
models_dir: {
|
||||
invalid: true,
|
||||
pending: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const commitSavedSnapshot = useCallback((next: RuntimeSnapshot, keepOpen: boolean) => {
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
liveSnapshot: next,
|
||||
savedBaseline: cloneConfig(next.config),
|
||||
draftConfig: cloneConfig(next.config),
|
||||
isDirty: false,
|
||||
isOpen: keepOpen,
|
||||
fields: createRuntimeDraftFieldState(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const saveDraft = useCallback(async () => {
|
||||
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "save");
|
||||
if (blockedSnapshot) {
|
||||
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
|
||||
return blockedSnapshot;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "save", current.liveSnapshot.status),
|
||||
}));
|
||||
try {
|
||||
const next = await saveRuntimeConfig(draftState.draftConfig);
|
||||
const resolvedNext = finalizeLifecycleSnapshot("save", "Save completed.", next);
|
||||
commitSavedSnapshot(resolvedNext, true);
|
||||
return resolvedNext;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [commitSavedSnapshot, draftState.draftConfig]);
|
||||
|
||||
const saveConfig = useCallback(async (patch: Partial<RuntimeConfig>) => {
|
||||
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "save");
|
||||
if (blockedSnapshot) {
|
||||
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
|
||||
return blockedSnapshot;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "save", current.liveSnapshot.status),
|
||||
}));
|
||||
try {
|
||||
const next = await saveRuntimeConfig({ ...draftState.draftConfig, ...patch });
|
||||
const resolvedNext = finalizeLifecycleSnapshot("save", "Save completed.", next);
|
||||
commitSavedSnapshot(resolvedNext, draftState.isOpen);
|
||||
return resolvedNext;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [commitSavedSnapshot, draftState.draftConfig, draftState.isOpen]);
|
||||
|
||||
const startLocal = useCallback(async () => {
|
||||
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "start");
|
||||
if (blockedSnapshot) {
|
||||
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
|
||||
return blockedSnapshot;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "start", "starting"),
|
||||
}));
|
||||
try {
|
||||
const next = await startRuntime();
|
||||
const resolvedNext = finalizeLifecycleSnapshot("start", "Local API started.", next);
|
||||
setDraftState((current) => reconcileDraftState(current, resolvedNext));
|
||||
return resolvedNext;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [draftState.liveSnapshot]);
|
||||
|
||||
const stopLocal = useCallback(async () => {
|
||||
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "stop");
|
||||
if (blockedSnapshot) {
|
||||
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
|
||||
return blockedSnapshot;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "stop", "stopping"),
|
||||
}));
|
||||
try {
|
||||
const next = await stopRuntime();
|
||||
const resolvedNext = finalizeLifecycleSnapshot("stop", "Local API stopped.", next);
|
||||
setDraftState((current) => reconcileDraftState(current, resolvedNext));
|
||||
return resolvedNext;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [draftState.liveSnapshot]);
|
||||
|
||||
const connection = useMemo(() => getRuntimeConnection(draftState.liveSnapshot), [draftState.liveSnapshot]);
|
||||
const activeLifecycleCommand = draftState.liveSnapshot.active_command;
|
||||
const lastLifecycleResult: RuntimeLifecycleResult = draftState.liveSnapshot.last_command_result;
|
||||
const lifecycleBusy = activeLifecycleCommand !== "idle";
|
||||
const applyImpact: RuntimeApplyImpact = useMemo(
|
||||
() =>
|
||||
getRuntimeApplyImpact({
|
||||
savedBaseline: draftState.savedBaseline,
|
||||
draftConfig: draftState.draftConfig,
|
||||
processRunning: draftState.liveSnapshot.process_running,
|
||||
}),
|
||||
[draftState.draftConfig, draftState.liveSnapshot.process_running, draftState.savedBaseline],
|
||||
);
|
||||
|
||||
// Phase 3: Unified workspace contract
|
||||
const workspaceSummary: RuntimeWorkspaceSummary = useMemo(
|
||||
() => getRuntimeWorkspaceSummary(draftState.liveSnapshot),
|
||||
[draftState.liveSnapshot],
|
||||
);
|
||||
const workspaceHealth: RuntimeWorkspaceHealth = useMemo(
|
||||
() => getRuntimeHealthSummary(draftState.liveSnapshot),
|
||||
[draftState.liveSnapshot],
|
||||
);
|
||||
|
||||
return {
|
||||
snapshot: draftState.liveSnapshot,
|
||||
liveSnapshot: draftState.liveSnapshot,
|
||||
savedBaseline: draftState.savedBaseline,
|
||||
draftConfig: draftState.draftConfig,
|
||||
draftDirty: draftState.isDirty,
|
||||
draftOpen: draftState.isOpen,
|
||||
draftFields: draftState.fields,
|
||||
connection,
|
||||
loading,
|
||||
busy,
|
||||
activeLifecycleCommand,
|
||||
lastLifecycleResult,
|
||||
lifecycleBusy,
|
||||
applyImpact,
|
||||
// Phase 3: Unified workspace contract
|
||||
workspaceSummary,
|
||||
workspaceHealth,
|
||||
refresh,
|
||||
openDraft,
|
||||
closeDraft,
|
||||
resetDraft,
|
||||
updateDraft,
|
||||
updateDraftField,
|
||||
updateDraftFieldMeta,
|
||||
saveDraft,
|
||||
saveConfig,
|
||||
startLocal,
|
||||
stopLocal,
|
||||
// Phase 4: Model scanning for validation
|
||||
scannedModels: draftState.scannedModels,
|
||||
isScanningModels: draftState.isScanning,
|
||||
scanModels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeStatus } from "./types";
|
||||
import { useRuntimeReadyModelRefresh } from "./useRuntimeReadyModelRefresh";
|
||||
|
||||
function Harness({ status, onRefresh }: { status: RuntimeStatus; onRefresh: () => void }) {
|
||||
useRuntimeReadyModelRefresh(status, onRefresh);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useRuntimeReadyModelRefresh", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("refreshes once when the runtime transitions into ready", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness status="stopped" onRefresh={onRefresh} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness status="ready" onRefresh={onRefresh} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness status="ready" onRefresh={onRefresh} />);
|
||||
});
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes again after leaving ready and coming back", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness status="ready" onRefresh={onRefresh} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness status="degraded" onRefresh={onRefresh} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness status="ready" onRefresh={onRefresh} />);
|
||||
});
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores non-ready transitions", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness status="starting" onRefresh={onRefresh} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness status="stopping" onRefresh={onRefresh} />);
|
||||
});
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RuntimeStatus } from "./types";
|
||||
|
||||
export function useRuntimeReadyModelRefresh(status: RuntimeStatus, onRefresh: () => void) {
|
||||
const previousStatus = useRef<RuntimeStatus>(status);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "ready" && previousStatus.current !== "ready") {
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
previousStatus.current = status;
|
||||
}, [status, onRefresh]);
|
||||
}
|
||||
Reference in new issue
Block a user