312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|