fix(web): refresh dashboard model picker

This commit is contained in:
helix4u 2026-07-06 12:25:26 -06:00 committed by Teknium
parent b3bee33ab3
commit 830165473e
3 changed files with 123 additions and 21 deletions

View file

@ -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<ModelOptionsResponse>;
loader?(options?: { refresh?: boolean }): Promise<ModelOptionsResponse>;
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<PendingExpensiveConfirm | null>(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<ModelOptionsResponse>)({
refresh,
})
: (gw as GatewayClient).request<ModelOptionsResponse>(
"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<ModelOptionsResponse>)()
: (gw as GatewayClient).request<ModelOptionsResponse>(
"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) {
)}
<div className="flex items-center gap-2 ml-auto">
<Button
outlined
onClick={refreshOptions}
disabled={applying || loading || refreshing}
>
{refreshing ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
Refresh Models
</Button>
<Button outlined onClick={onClose} disabled={applying}>
Cancel
</Button>

48
web/src/lib/api.test.ts Normal file
View file

@ -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" }),
);
});
});

View file

@ -475,8 +475,21 @@ export const api = {
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
getModelOptions: (profile?: string) =>
fetchJSON<ModelOptionsResponse>(`/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<ModelOptionsResponse>(`/api/model/options${suffix}`);
},
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
getMoaModels: () => fetchJSON<MoaConfigResponse>("/api/model/moa"),
saveMoaModels: (body: MoaConfigResponse) =>