From b279f16ba0e1537d2752d30bf5e565f4ede400af Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 8 Jul 2026 19:37:17 -0500 Subject: [PATCH] feat(agent): refresh assistant model catalog --- src/app/api/agent/models/route.ts | 25 ++++-- src/lib/llm/models.ts | 7 +- .../agent/components/assistant-panel.tsx | 76 ++++++++++++------- tests/e2e/assistant.spec.ts | 3 + tests/unit/agent-models-route.test.ts | 17 +++++ tests/unit/llm-models.test.ts | 24 ++++-- 6 files changed, 111 insertions(+), 41 deletions(-) create mode 100644 tests/unit/agent-models-route.test.ts diff --git a/src/app/api/agent/models/route.ts b/src/app/api/agent/models/route.ts index a86fa8f..bfe5670 100644 --- a/src/app/api/agent/models/route.ts +++ b/src/app/api/agent/models/route.ts @@ -3,15 +3,22 @@ import { resolveApiAuth } from "@/lib/api-auth"; import { getAssistantPreferences } from "@/lib/assistant-preference"; import { listLlmModels, resolveAssistantModel } from "@/lib/llm/models"; +export const dynamic = "force-dynamic"; + +function noStore(response: Response): Response { + response.headers.set("Cache-Control", "no-store"); + return response; +} + export async function GET(request: Request) { const auth = await resolveApiAuth(request); if (!auth?.userId) { - return apiError("Unauthorized", 401); + return noStore(apiError("Unauthorized", 401)); } const assistant = await getAssistantPreferences(auth.userId); if (!assistant.enabled) { - return apiError("Assistant not enabled", 403); + return noStore(apiError("Assistant not enabled", 403)); } const modelList = await listLlmModels(); @@ -22,10 +29,12 @@ export async function GET(request: Request) { models: modelList.models, }); - return apiJson({ - models: modelList.models, - selectedModel: resolved.model, - fallbackModel: modelList.fallbackModel, - degraded: modelList.degraded, - }); + return noStore( + apiJson({ + models: modelList.models, + selectedModel: resolved.model, + fallbackModel: modelList.fallbackModel, + degraded: modelList.degraded, + }), + ); } diff --git a/src/lib/llm/models.ts b/src/lib/llm/models.ts index 6d0fdb3..ea7567f 100644 --- a/src/lib/llm/models.ts +++ b/src/lib/llm/models.ts @@ -65,7 +65,12 @@ export async function listLlmModels(options?: { const headers: Record = {}; if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; - const response = await fetchImpl(`${config.baseUrl.replace(/\/$/, "")}/models`, { + const modelsUrl = new URL(`${config.baseUrl.replace(/\/$/, "")}/models`); + if (fallbackModel === "uncensored") { + modelsUrl.searchParams.set("type", "uncensored"); + } + + const response = await fetchImpl(modelsUrl, { method: "GET", headers, }); diff --git a/src/modules/agent/components/assistant-panel.tsx b/src/modules/agent/components/assistant-panel.tsx index 1a786d7..6c06688 100644 --- a/src/modules/agent/components/assistant-panel.tsx +++ b/src/modules/agent/components/assistant-panel.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useRef, useState, useTransition } from "react"; -import { ChevronDown, ImagePlus, Loader2, Mic, Send, Square } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import { ChevronDown, ImagePlus, Loader2, Mic, RefreshCw, Send, Square } from "lucide-react"; import { setAssistantModel } from "@/app/settings/assistant-actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -87,34 +87,44 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod }; }, []); - useEffect(() => { - let cancelled = false; + const loadModels = useCallback(async (options?: { refresh?: boolean; signal?: AbortSignal }) => { + if (options?.signal?.aborted) return; - async function loadModels() { - setModelsLoading(true); - try { - const response = await fetch("/api/agent/models"); - if (!response.ok) throw new Error("Model discovery unavailable"); - const payload = (await response.json()) as ModelsResponse; - if (cancelled) return; - setModelOptions(payload.models); - setSelectedModel(payload.selectedModel); - setFallbackModel(payload.fallbackModel); - setModelsDegraded(payload.degraded); - } catch { - if (cancelled) return; - setModelsDegraded(true); - } finally { - if (!cancelled) setModelsLoading(false); - } + setModelsLoading(true); + try { + const response = await fetch(`/api/agent/models${options?.refresh ? "?refresh=1" : ""}`, { + cache: "no-store", + signal: options?.signal, + }); + if (!response.ok) throw new Error("Model discovery unavailable"); + const payload = (await response.json()) as ModelsResponse; + if (options?.signal?.aborted) return; + setModelOptions(payload.models); + setSelectedModel(payload.selectedModel); + setFallbackModel(payload.fallbackModel); + setModelsDegraded(payload.degraded); + setError(null); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return; + setModelsDegraded(true); + setError("Model discovery unavailable"); + } finally { + if (!options?.signal?.aborted) setModelsLoading(false); } - - void loadModels(); - return () => { - cancelled = true; - }; }, []); + useEffect(() => { + const controller = new AbortController(); + + queueMicrotask(() => { + void loadModels({ signal: controller.signal }); + }); + + return () => { + controller.abort(); + }; + }, [loadModels]); + function scrollToBottom() { requestAnimationFrame(() => { const node = listRef.current; @@ -271,6 +281,20 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod /> ) : null} + {messages.length > 0 ? (