Files
EvoScientist-WebUI/src/app/hooks/useAvailableModels.ts
T
m4 fe982f7f95
CI / macos-latest / Node 20 (push) Has been cancelled
CI / ubuntu-latest / Node 20 (push) Has been cancelled
CI / windows-latest / Node 20 (push) Has been cancelled
feat: add workspace isolation and administration UI
2026-07-19 12:17:18 +08:00

150 lines
4.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
export interface ModelRegistryEntry {
/** Short name as the user types in `/model <name>`. */
name: string;
/** Provider-specific model id (often equal to `name`). Informational only;
* the WebUI doesn't pass it to the run config. */
model_id: string;
/** Provider key the backend uses to route the call. */
provider: string;
}
export interface ModelRegistry {
entries: ReadonlyArray<ModelRegistryEntry>;
/** Effective default reported by the deployment. This is the persisted pair
* when available, otherwise the first enabled registry model. May be null
* when no model is configured or on older deployments. */
defaultEntry: { name: string; provider: string | null } | null;
}
interface RegistryResponse {
entries?: unknown;
default?: unknown;
}
const EMPTY: ModelRegistry = { entries: [], defaultEntry: null };
let cachedRegistry: Promise<ModelRegistry> | null = null;
const MODELS_CHANGED_EVENT = "evoscientist-models-changed";
export function invalidateAvailableModels(): void {
cachedRegistry = null;
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(MODELS_CHANGED_EVENT));
}
}
function fetchRegistry(): Promise<ModelRegistry> {
if (cachedRegistry) return cachedRegistry;
const fetchJson = async (url: string): Promise<RegistryResponse> => {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()) as RegistryResponse;
};
const pending = fetchJson("/api/models")
.then(async (r) => {
const entries: ModelRegistryEntry[] = [];
if (Array.isArray(r.entries)) {
for (const raw of r.entries) {
if (!raw || typeof raw !== "object") continue;
const e = raw as {
name?: unknown;
model_id?: unknown;
provider?: unknown;
};
if (
typeof e.name === "string" &&
typeof e.provider === "string" &&
e.name &&
e.provider
) {
entries.push({
name: e.name,
model_id: typeof e.model_id === "string" ? e.model_id : e.name,
provider: e.provider,
});
}
}
}
let defaultEntry: ModelRegistry["defaultEntry"] = null;
if (r.default && typeof r.default === "object") {
const d = r.default as { name?: unknown; provider?: unknown };
if (typeof d.name === "string" && d.name) {
defaultEntry = {
name: d.name,
provider:
typeof d.provider === "string" && d.provider ? d.provider : null,
};
}
}
return { entries, defaultEntry } as ModelRegistry;
})
.catch((err: unknown) => {
cachedRegistry = null;
throw err;
});
cachedRegistry = pending;
return pending;
}
/**
* Fetch the backend's authoritative model registry through the WebUI's
* same-origin `/api/models` proxy first, falling back to
* the same-origin model registry endpoint. Results
* are cached at module level — the registry is static between deployment
* restarts, so remounting ChatInterface never triggers a redundant network
* request.
*
* Failures are non-fatal but do not expose a hard-coded fallback list: the
* picker must only display models returned by the deployment configuration.
* Failed fetches are evicted from the cache so the next mount retries.
*/
export function useAvailableModels(): {
registry: ModelRegistry;
loading: boolean;
error: string | null;
} {
const [registry, setRegistry] = useState<ModelRegistry>(EMPTY);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshRevision, setRefreshRevision] = useState(0);
useEffect(() => {
const refresh = () => setRefreshRevision((value) => value + 1);
window.addEventListener(MODELS_CHANGED_EVENT, refresh);
return () => window.removeEventListener(MODELS_CHANGED_EVENT, refresh);
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetchRegistry()
.then((result) => {
if (cancelled) return;
setRegistry(result);
setError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load models.");
setRegistry(EMPTY);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [refreshRevision]);
return { registry, loading, error };
}