150 lines
4.6 KiB
TypeScript
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 };
|
|
}
|