mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(web): refresh dashboard model picker
This commit is contained in:
parent
b3bee33ab3
commit
830165473e
3 changed files with 123 additions and 21 deletions
|
|
@ -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
48
web/src/lib/api.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue