Moved the useEffect that auto-scans models below the definition of scanModels to fix a ReferenceError caused by the Temporal Dead Zone (TDZ) when calling a block-scoped useCallback function before it was initialized.
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, 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) => {
|
|
const resolvedDir = dirPath ?? "";
|
|
|
|
// Set scanning state first
|
|
setDraftState((current) => ({
|
|
...current,
|
|
isScanning: true,
|
|
}));
|
|
|
|
try {
|
|
const models = await scanRuntimeModels(resolvedDir);
|
|
|
|
// 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),
|
|
},
|
|
},
|
|
}));
|
|
}
|
|
}, []);
|
|
|
|
// Auto-scan models when the configured directory changes
|
|
useEffect(() => {
|
|
const modelsDir = draftState.liveSnapshot.config.models_dir;
|
|
if (modelsDir) {
|
|
scanModels(modelsDir).catch(() => undefined);
|
|
}
|
|
}, [draftState.liveSnapshot.config.models_dir, scanModels]);
|
|
|
|
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,
|
|
};
|
|
}
|