diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index d9adc119800..554288e60ec 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -6,7 +6,7 @@ import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import type { GatewayClient } from "@/lib/gatewayClient"; -import { Check, Search, X } from "lucide-react"; +import { Check, RefreshCw, Search, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn, themedBody } from "@/lib/utils"; @@ -71,7 +71,7 @@ interface Props { onSubmit?(slashCommand: string): void; /** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */ - loader?(): Promise; + loader?(options?: { refresh?: boolean }): Promise; onApply?(args: { confirmExpensiveModel?: boolean; provider: string; @@ -111,37 +111,70 @@ export function ModelPickerDialog(props: Props) { const [query, setQuery] = useState(""); const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal); const [applying, setApplying] = useState(false); + const [refreshing, setRefreshing] = useState(false); const [pendingConfirm, setPendingConfirm] = useState(null); const closedRef = useRef(false); + const applyOptions = (r: ModelOptionsResponse) => { + const next = r?.providers ?? []; + setProviders(next); + setCurrentModel(String(r?.model ?? "")); + setCurrentProviderSlug(String(r?.provider ?? "")); + setSelectedSlug((prev) => { + if (prev && next.some((p) => p.slug === prev)) return prev; + return (next.find((p) => p.is_current) ?? next[0])?.slug ?? ""; + }); + setSelectedModel(""); + }; + + const requestOptions = (refresh = false) => + standalone + ? (loader as (options?: { refresh?: boolean }) => Promise)({ + refresh, + }) + : (gw as GatewayClient).request( + "model.options", + { + ...(sessionId ? { session_id: sessionId } : {}), + ...(refresh ? { refresh: true } : {}), + }, + ); + + const refreshOptions = () => { + setError(null); + setRefreshing(true); + + requestOptions(true) + .then((r) => { + if (closedRef.current) return; + applyOptions(r); + }) + .catch((e) => { + if (closedRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (closedRef.current) return; + setRefreshing(false); + }); + }; + // Load providers + models on open. useEffect(() => { closedRef.current = false; - const promise = standalone - ? (loader as () => Promise)() - : (gw as GatewayClient).request( - "model.options", - sessionId ? { session_id: sessionId } : {}, - ); - - promise + requestOptions() .then((r) => { if (closedRef.current) return; - const next = r?.providers ?? []; - setProviders(next); - setCurrentModel(String(r?.model ?? "")); - setCurrentProviderSlug(String(r?.provider ?? "")); - setSelectedSlug( - (next.find((p) => p.is_current) ?? next[0])?.slug ?? "", - ); - setSelectedModel(""); - setLoading(false); + applyOptions(r); }) .catch((e) => { if (closedRef.current) return; setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (closedRef.current) return; setLoading(false); }); @@ -390,6 +423,14 @@ export function ModelPickerDialog(props: Props) { )}
+ diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts new file mode 100644 index 00000000000..eff359518c1 --- /dev/null +++ b/web/src/lib/api.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { api } from "./api"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("api.getModelOptions", () => { + it("requests a live model refresh when asked", async () => { + vi.stubGlobal("window", {}); + + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ providers: [] }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.getModelOptions({ refresh: true }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/model/options?refresh=1", + expect.objectContaining({ credentials: "include" }), + ); + }); + + it("keeps explicit profile scoping when refreshing", async () => { + vi.stubGlobal("window", {}); + + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ providers: [] }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.getModelOptions({ profile: "default", refresh: true }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/model/options?profile=default&refresh=1", + expect.objectContaining({ credentials: "include" }), + ); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index dff03b15587..1f14ee68b34 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -475,8 +475,21 @@ export const api = { getDefaults: () => fetchJSON>("/api/config/defaults"), getSchema: () => fetchJSON<{ fields: Record; category_order: string[] }>("/api/config/schema"), getModelInfo: () => fetchJSON("/api/model/info"), - getModelOptions: (profile?: string) => - fetchJSON(`/api/model/options${profileQuery(profile)}`), + getModelOptions: ( + profileOrOptions?: string | { profile?: string; refresh?: boolean }, + ) => { + const profile = + typeof profileOrOptions === "string" + ? profileOrOptions + : profileOrOptions?.profile; + const refresh = + typeof profileOrOptions === "object" && !!profileOrOptions.refresh; + const qs = new URLSearchParams(); + if (profile) qs.set("profile", profile); + if (refresh) qs.set("refresh", "1"); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return fetchJSON(`/api/model/options${suffix}`); + }, getAuxiliaryModels: () => fetchJSON("/api/model/auxiliary"), getMoaModels: () => fetchJSON("/api/model/moa"), saveMoaModels: (body: MoaConfigResponse) =>