diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 3f15627af..a9f24f753 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -5,10 +5,10 @@ import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; import { type ComboItem, parseComboList } from "../combo-workspace-data"; +import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { buildProviderModelGroups, type ConfiguredProviderSummary, - type ProviderDiscoverySummary, type ProviderModelGroup, } from "../models-groups"; import { @@ -20,75 +20,27 @@ import { type ModelVisibilityScope, type ModelVisibilityTarget, } from "../model-visibility"; - -interface ModelRow { - provider: string; - id: string; - namespaced: string; - disabled: boolean; - native?: boolean; - custom?: boolean; - customId?: string; - displayName?: string; - inputModalities?: string[]; - contextWindow?: number; - contextCap?: number; - contextCapped?: boolean; -} - -interface ProviderContextCapsResponse { - cap?: number; - value?: number; - caps?: Record; -} - -interface V2Status { - enabled: boolean; - agentsMaxThreadsConflict: boolean; - maxConcurrentThreadsPerSession?: number | null; - multiAgentMode?: "v1" | "default" | "v2"; -} - -interface ShadowCallData { - enabled: boolean; - model: string; -} - -const CAP_OPTIONS = Array.from({ length: 18 }, (_, i) => 100_000 + i * 50_000); // 100k … 950k -const CAP_OPTION_SET = new Set(CAP_OPTIONS); -const CUSTOM_OPTION = "custom"; -const THREAD_OPTIONS = [4, 8, 16, 32, 64, 128, 256, 500, 1000]; -const THREAD_OPTION_SET = new Set(THREAD_OPTIONS); -const PAGE = 60; // rows rendered per provider before a "show more" (keeps 1000s-of-models providers usable) - -/** Compact token display (350k) — unit is technical, not prose. */ -function fmtK(n: number): string { - if (!Number.isFinite(n) || n <= 0) return String(n); - return n % 1000 === 0 ? `${n / 1000}k` : n.toLocaleString(); -} - -function collectDisabledNamespaced(rows: ModelRow[]): Set { - const next = new Set(); - for (const m of rows) { - if (m.disabled) next.add(m.namespaced); - } - return next; -} - -function activeModelOptions( - models: ModelRow[], - disabled: Set, - selected: ProviderModelMap, -): { value: string; label: string }[] { - const options: { value: string; label: string }[] = []; - for (const m of models) { - const blocked = disabled.has(m.id) || disabled.has(m.namespaced); - if (modelVisible(selected, m.provider, m.id, m.native === true, blocked)) { - options.push({ value: m.namespaced, label: m.namespaced }); - } - } - return options; -} +import { + activeModelOptions, + CAP_OPTION_SET, + CAP_OPTIONS, + collectDisabledNamespaced, + CUSTOM_OPTION, + fmtK, + PAGE, + readCollapsedProviders, + readCombosOpen, + THREAD_OPTION_SET, + THREAD_OPTIONS, + writeCollapsedProviders, + writeCombosOpen, + discoveryFailureLabel, + type ModelRow, + type ProviderContextCapsResponse, + type ShadowCallData, + type V2Status, +} from "./models-shared"; +import { EmptyProviderHint } from "./models-provider-hints"; export default function Models({ apiBase }: { apiBase: string }) { const t: TFn = useT(); @@ -102,12 +54,7 @@ export default function Models({ apiBase }: { apiBase: string }) { const [contextCapValue, setContextCapValue] = useState(350_000); const [customCap, setCustomCap] = useState(""); const [showCustom, setShowCustom] = useState(false); - const [collapsed, setCollapsed] = useState>(() => { - try { - const saved = localStorage.getItem("ocx-models-collapsed"); - return saved ? new Set(JSON.parse(saved) as string[]) : new Set(); - } catch { return new Set(); } - }); + const [collapsed, setCollapsed] = useState>(readCollapsedProviders); const [status, setStatus] = useState(""); const [ok, setOk] = useState(false); const [loading, setLoading] = useState(true); @@ -142,26 +89,33 @@ export default function Models({ apiBase }: { apiBase: string }) { // an API error must never masquerade as "no combos configured"). const [combos, setCombos] = useState(null); const [combosError, setCombosError] = useState(false); - const [combosOpen, setCombosOpen] = useState(() => { - try { return localStorage.getItem("ocx-models-combos-open") === "1"; } catch { return false; } - }); + const [combosOpen, setCombosOpen] = useState(readCombosOpen); // App owns the in-session view mode; fallback to persisted mode for isolated renders/tests. const [selectedProvider, setSelectedProvider] = useState(null); const toggleCombosOpen = () => { - setCombosOpen(prev => { - const next = !prev; - try { localStorage.setItem("ocx-models-combos-open", next ? "1" : "0"); } catch { /* ignore */ } - return next; - }); + const next = !combosOpen; + writeCombosOpen(next); + setCombosOpen(next); }; useEffect(() => { let cancelled = false; - fetch(`${apiBase}/api/combos`) - .then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status)))) - .then((j: unknown) => { if (!cancelled) { setCombos(parseComboList(j)); setCombosError(false); } }) - .catch(() => { if (!cancelled) { setCombos(null); setCombosError(true); } }); + void (async () => { + try { + const r = await fetch(`${apiBase}/api/combos`); + const j = await readJsonOrThrow(r); + if (!cancelled) { + setCombos(parseComboList(j)); + setCombosError(false); + } + } catch { + if (!cancelled) { + setCombos(null); + setCombosError(true); + } + } + })(); return () => { cancelled = true; }; }, [apiBase]); @@ -177,7 +131,8 @@ export default function Models({ apiBase }: { apiBase: string }) { const loadShadowCall = useCallback(async () => { try { const r = await fetch(`${apiBase}/api/shadow-call-settings`); - if (r.ok) setShadowCall(await r.json() as ShadowCallData); + const data = await readJsonIfOk(r); + if (data) setShadowCall(data); } catch { /* old server / network: keep the section disabled */ } }, [apiBase]); @@ -186,16 +141,15 @@ export default function Models({ apiBase }: { apiBase: string }) { if (v2BusyRef.current) return; try { const r = await fetch(`${apiBase}/api/v2`); - if (!r.ok || !(r.headers.get("content-type") ?? "").includes("application/json")) { setV2(null); return; } - const data = await r.json() as V2Status; - if (typeof data.enabled === "boolean") { - setV2({ - enabled: data.enabled, - agentsMaxThreadsConflict: data.agentsMaxThreadsConflict === true, - maxConcurrentThreadsPerSession: typeof data.maxConcurrentThreadsPerSession === "number" ? data.maxConcurrentThreadsPerSession : null, - multiAgentMode: data.multiAgentMode === "v1" || data.multiAgentMode === "v2" ? data.multiAgentMode : "default", - }); - } + if (!(r.headers.get("content-type") ?? "").includes("application/json")) { setV2(null); return; } + const data = await readJsonIfOk(r); + if (!data || typeof data.enabled !== "boolean") { setV2(null); return; } + setV2({ + enabled: data.enabled, + agentsMaxThreadsConflict: data.agentsMaxThreadsConflict === true, + maxConcurrentThreadsPerSession: typeof data.maxConcurrentThreadsPerSession === "number" ? data.maxConcurrentThreadsPerSession : null, + multiAgentMode: data.multiAgentMode === "v1" || data.multiAgentMode === "v2" ? data.multiAgentMode : "default", + }); } catch { setV2(null); // old server / network: hide the section instead of guessing } @@ -206,14 +160,20 @@ export default function Models({ apiBase }: { apiBase: string }) { loadPendingRef.current = true; const generation = ++loadGenerationRef.current; try { - const [data, capsData] = await Promise.all([ - fetch(`${apiBase}/api/models`).then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status)))) as Promise, - fetch(`${apiBase}/api/provider-context-caps`).then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status)))) as Promise, - ]); - const [providerData, selectionData] = await Promise.all([ - fetch(`${apiBase}/api/providers`).then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status)))) as Promise, + const [modelsRes, capsRes, providersRes, selectionData] = await Promise.all([ + fetch(`${apiBase}/api/models`), + fetch(`${apiBase}/api/provider-context-caps`), + fetch(`${apiBase}/api/providers`), fetchSelectedModels(apiBase), ]); + const [data, capsData, providerData] = await Promise.all([ + readJsonOrThrow(modelsRes), + readJsonOrThrow(capsRes), + readJsonOrThrow(providersRes), + ]); + if (data === undefined || capsData === undefined || providerData === undefined) { + throw new Error("models payload missing"); + } if (!shouldApplyLoadGeneration(generation, loadGenerationRef.current)) return false; void loadV2(); // best-effort, independent of the models fetch void loadShadowCall(); @@ -320,15 +280,15 @@ export default function Models({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, enabled }), }); - if (r.ok) { - const data = (await r.json()) as ProviderContextCapsResponse; - setContextCaps(data.caps ?? {}); + try { + const data = await readJsonOrThrow(r, t("models.capSaveFailed")); + setContextCaps(data?.caps ?? {}); setOk(true); setStatus(t("models.capApplied")); await load(true); - } else { + } catch (e) { setOk(false); - setStatus(t("models.capSaveFailed")); + setStatus(e instanceof Error ? e.message : t("models.capSaveFailed")); } } catch { setOk(false); setStatus(t("models.networkError")); @@ -341,14 +301,14 @@ export default function Models({ apiBase }: { apiBase: string }) { setCollapsed(prev => { const n = new Set(prev); if (n.has(p)) n.delete(p); else n.add(p); - try { localStorage.setItem("ocx-models-collapsed", JSON.stringify([...n])); } catch { /* quota */ } + writeCollapsedProviders(n); return n; }); }; const setAllCollapsed = (collapse: boolean) => { setCollapsed(() => { const n = collapse ? new Set(groups.map(group => group.provider)) : new Set(); - try { localStorage.setItem("ocx-models-collapsed", JSON.stringify([...n])); } catch { /* quota */ } + writeCollapsedProviders(n); return n; }); }; @@ -363,16 +323,16 @@ export default function Models({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (r.ok) { - const data = (await r.json()) as ProviderContextCapsResponse; - if (typeof data.value === "number" && Number.isFinite(data.value) && data.value > 0) setContextCapValue(data.value); - setContextCaps(data.caps ?? {}); + try { + const data = await readJsonOrThrow(r, t("models.capSaveFailed")); + if (typeof data?.value === "number" && Number.isFinite(data.value) && data.value > 0) setContextCapValue(data.value); + setContextCaps(data?.caps ?? {}); setOk(true); setStatus(t("models.capApplied")); await load(true); - } else { + } catch (e) { setOk(false); - setStatus(t("models.capSaveFailed")); + setStatus(e instanceof Error ? e.message : t("models.capSaveFailed")); } } catch { setOk(false); setStatus(t("models.networkError")); @@ -439,15 +399,15 @@ export default function Models({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ multiAgentMode: mode }), }); - const data = await r.json().catch(() => null) as V2Status & { warnings?: string[]; error?: string } | null; - if (r.ok && data) { + try { + const data = await readJsonOrThrow(r, t("models.saveFailed")); void loadV2(); setOk(true); setStatus(t("models.v2Applied")); - setV2Note((data.warnings ?? []).join(" ")); - } else { + setV2Note((data?.warnings ?? []).join(" ")); + } catch (e) { setOk(false); - setStatus(data?.error ?? t("models.saveFailed")); + setStatus(e instanceof Error ? e.message : t("models.saveFailed")); } } catch { setOk(false); setStatus(t("models.networkError")); @@ -474,9 +434,14 @@ export default function Models({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ maxConcurrentThreadsPerSession: value }), }); - const data = await r.json().catch(() => null) as V2Status & { warnings?: string[]; error?: string } | null; - if (r.ok && data && typeof data.enabled === "boolean") { - setV2({ + try { + const data = await readJsonOrThrow(r, t("models.saveFailed")); + if (!data || typeof data.enabled !== "boolean") { + setOk(false); + setStatus(t("models.saveFailed")); + return; + } + setV2({ enabled: data.enabled, agentsMaxThreadsConflict: data.agentsMaxThreadsConflict === true, maxConcurrentThreadsPerSession: typeof data.maxConcurrentThreadsPerSession === "number" ? data.maxConcurrentThreadsPerSession : null, @@ -485,9 +450,9 @@ export default function Models({ apiBase }: { apiBase: string }) { setOk(true); setStatus(t("models.v2ThreadsApplied")); setShowThreadsCustom(false); - } else { + } catch (e) { setOk(false); - setStatus(data?.error ?? t("models.saveFailed")); + setStatus(e instanceof Error ? e.message : t("models.saveFailed")); } } catch { setOk(false); setStatus(t("models.networkError")); @@ -539,14 +504,14 @@ export default function Models({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, modelId, displayName, contextWindow, inputModalities }), }); - if (r.ok) { + try { + await readJsonOrThrow(r, t("models.customSaveFailed")); setCustomModalOpen(false); setOk(true); setStatus(t("models.customAdded")); await load(true); - } else { - const data = await r.json().catch(() => null) as { error?: string } | null; - setCustomError(data?.error ?? t("models.customSaveFailed")); + } catch (e) { + setCustomError(e instanceof Error ? e.message : t("models.customSaveFailed")); } } catch { setCustomError(t("models.networkError")); @@ -564,14 +529,14 @@ export default function Models({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); - if (r.ok) { + try { + await readJsonOrThrow(r, t("models.customSaveFailed")); setCustomModalOpen(false); setOk(true); setStatus(t("models.customUpdated")); await load(true); - } else { - const data = await r.json().catch(() => null) as { error?: string } | null; - setCustomError(data?.error ?? t("models.customSaveFailed")); + } catch (e) { + setCustomError(e instanceof Error ? e.message : t("models.customSaveFailed")); } } catch { setCustomError(t("models.networkError")); @@ -655,7 +620,7 @@ export default function Models({ apiBase }: { apiBase: string }) { {t("models.discoveryFailedBadge")} @@ -1300,48 +1265,3 @@ export default function Models({ apiBase }: { apiBase: string }) { ); } - -function discoveryFailureReason( - t: ReturnType, - discovery: Extract, -): string { - switch (discovery.reason) { - case "http": - return t("models.discoveryFailedHttp", { status: discovery.httpStatus }); - case "blocked": - return t("models.discoveryFailedBlocked"); - case "invalid_response": - return t("models.discoveryFailedInvalidResponse"); - case "network": - return t("models.discoveryFailedNetwork"); - case "provider": - return t("models.discoveryFailedProvider"); - default: - return t("models.discoveryFailedGeneric"); - } -} - -export function EmptyProviderHint({ - liveModels, - discovery, - showFailureBadge = true, -}: { - liveModels: boolean; - discovery?: ProviderDiscoverySummary; - showFailureBadge?: boolean; -}) { - const t = useT(); - const failed = liveModels && discovery?.status === "failed" ? discovery : undefined; - return ( -
-
- ); -} diff --git a/gui/src/pages/models-provider-hints.tsx b/gui/src/pages/models-provider-hints.tsx new file mode 100644 index 000000000..366d25ac2 --- /dev/null +++ b/gui/src/pages/models-provider-hints.tsx @@ -0,0 +1,29 @@ +import { IconInfo } from "../icons"; +import { useT } from "../i18n/shared"; +import type { ProviderDiscoverySummary } from "../models-groups"; +import { discoveryFailureLabel } from "./models-shared"; + +export function EmptyProviderHint({ + liveModels, + discovery, + showFailureBadge = true, +}: { + liveModels: boolean; + discovery?: ProviderDiscoverySummary; + showFailureBadge?: boolean; +}) { + const t = useT(); + const failed = liveModels && discovery?.status === "failed" ? discovery : undefined; + return ( +
+
+ ); +} diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts new file mode 100644 index 000000000..046243df5 --- /dev/null +++ b/gui/src/pages/models-shared.ts @@ -0,0 +1,133 @@ +import type { TFn } from "../i18n/shared"; +import type { ProviderDiscoverySummary } from "../models-groups"; +import { modelVisible, type ProviderModelMap } from "../model-visibility"; + +export type StorageLike = Pick; + +export function discoveryFailureLabel( + t: TFn, + discovery: Extract, +): string { + switch (discovery.reason) { + case "http": + return t("models.discoveryFailedHttp", { status: discovery.httpStatus }); + case "blocked": + return t("models.discoveryFailedBlocked"); + case "invalid_response": + return t("models.discoveryFailedInvalidResponse"); + case "network": + return t("models.discoveryFailedNetwork"); + case "provider": + return t("models.discoveryFailedProvider"); + default: + return t("models.discoveryFailedGeneric"); + } +} + +export interface ModelRow { + provider: string; + id: string; + namespaced: string; + disabled: boolean; + native?: boolean; + custom?: boolean; + customId?: string; + displayName?: string; + inputModalities?: string[]; + contextWindow?: number; + contextCap?: number; + contextCapped?: boolean; +} + +export interface ProviderContextCapsResponse { + cap?: number; + value?: number; + caps?: Record; +} + +export interface V2Status { + enabled: boolean; + agentsMaxThreadsConflict: boolean; + maxConcurrentThreadsPerSession?: number | null; + multiAgentMode?: "v1" | "default" | "v2"; +} + +export interface ShadowCallData { + enabled: boolean; + model: string; +} + +export const CAP_OPTIONS = Array.from({ length: 18 }, (_, i) => 100_000 + i * 50_000); // 100k … 950k +export const CAP_OPTION_SET = new Set(CAP_OPTIONS); +export const CUSTOM_OPTION = "custom"; +export const THREAD_OPTIONS = [4, 8, 16, 32, 64, 128, 256, 500, 1000]; +export const THREAD_OPTION_SET = new Set(THREAD_OPTIONS); +export const PAGE = 60; // rows rendered per provider before a "show more" + +export const COLLAPSED_KEY_V1 = "ocx-models-collapsed:v1"; +export const COLLAPSED_KEY_LEGACY = "ocx-models-collapsed"; +export const COMBOS_OPEN_KEY_V1 = "ocx-models-combos-open:v1"; +export const COMBOS_OPEN_KEY_LEGACY = "ocx-models-combos-open"; + +/** Compact token display (350k) — unit is technical, not prose. */ +export function fmtK(n: number): string { + if (!Number.isFinite(n) || n <= 0) return String(n); + return n % 1000 === 0 ? `${n / 1000}k` : n.toLocaleString(); +} + +export function collectDisabledNamespaced(rows: ModelRow[]): Set { + const next = new Set(); + for (const m of rows) { + if (m.disabled) next.add(m.namespaced); + } + return next; +} + +export function activeModelOptions( + models: ModelRow[], + disabled: Set, + selected: ProviderModelMap, +): { value: string; label: string }[] { + const options: { value: string; label: string }[] = []; + for (const m of models) { + const blocked = disabled.has(m.id) || disabled.has(m.namespaced); + if (modelVisible(selected, m.provider, m.id, m.native === true, blocked)) { + options.push({ value: m.namespaced, label: m.namespaced }); + } + } + return options; +} + +export function readCollapsedProviders(storage: StorageLike = localStorage): Set { + try { + const saved = storage.getItem(COLLAPSED_KEY_V1) ?? storage.getItem(COLLAPSED_KEY_LEGACY); + return saved ? new Set(JSON.parse(saved) as string[]) : new Set(); + } catch { + return new Set(); + } +} + +export function writeCollapsedProviders(collapsed: Set, storage: StorageLike = localStorage): void { + try { + storage.setItem(COLLAPSED_KEY_V1, JSON.stringify([...collapsed])); + } catch { + /* quota / private-mode */ + } +} + +export function readCombosOpen(storage: StorageLike = localStorage): boolean { + try { + const saved = storage.getItem(COMBOS_OPEN_KEY_V1) ?? storage.getItem(COMBOS_OPEN_KEY_LEGACY); + return saved === "1"; + } catch { + return false; + } +} + +export function writeCombosOpen(open: boolean, storage: StorageLike = localStorage): void { + try { + storage.setItem(COMBOS_OPEN_KEY_V1, open ? "1" : "0"); + } catch { + /* quota / private-mode */ + } +} diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index 6868c4250..698e12372 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -5,7 +5,8 @@ import { act } from "react"; import type { Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { LanguageProvider } from "../src/i18n/provider"; -import Models, { EmptyProviderHint } from "../src/pages/Models"; +import Models from "../src/pages/Models"; +import { EmptyProviderHint } from "../src/pages/models-provider-hints"; import type { ProviderDiscoverySummary } from "../src/models-groups"; import { gatherRoutedModels } from "../../src/codex/catalog"; import {