feat(agent): refresh assistant model catalog
This commit is contained in:
@@ -3,15 +3,22 @@ import { resolveApiAuth } from "@/lib/api-auth";
|
|||||||
import { getAssistantPreferences } from "@/lib/assistant-preference";
|
import { getAssistantPreferences } from "@/lib/assistant-preference";
|
||||||
import { listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
|
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) {
|
export async function GET(request: Request) {
|
||||||
const auth = await resolveApiAuth(request);
|
const auth = await resolveApiAuth(request);
|
||||||
if (!auth?.userId) {
|
if (!auth?.userId) {
|
||||||
return apiError("Unauthorized", 401);
|
return noStore(apiError("Unauthorized", 401));
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant = await getAssistantPreferences(auth.userId);
|
const assistant = await getAssistantPreferences(auth.userId);
|
||||||
if (!assistant.enabled) {
|
if (!assistant.enabled) {
|
||||||
return apiError("Assistant not enabled", 403);
|
return noStore(apiError("Assistant not enabled", 403));
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelList = await listLlmModels();
|
const modelList = await listLlmModels();
|
||||||
@@ -22,10 +29,12 @@ export async function GET(request: Request) {
|
|||||||
models: modelList.models,
|
models: modelList.models,
|
||||||
});
|
});
|
||||||
|
|
||||||
return apiJson({
|
return noStore(
|
||||||
|
apiJson({
|
||||||
models: modelList.models,
|
models: modelList.models,
|
||||||
selectedModel: resolved.model,
|
selectedModel: resolved.model,
|
||||||
fallbackModel: modelList.fallbackModel,
|
fallbackModel: modelList.fallbackModel,
|
||||||
degraded: modelList.degraded,
|
degraded: modelList.degraded,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ export async function listLlmModels(options?: {
|
|||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
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",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useTransition } from "react";
|
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { ChevronDown, ImagePlus, Loader2, Mic, Send, Square } from "lucide-react";
|
import { ChevronDown, ImagePlus, Loader2, Mic, RefreshCw, Send, Square } from "lucide-react";
|
||||||
import { setAssistantModel } from "@/app/settings/assistant-actions";
|
import { setAssistantModel } from "@/app/settings/assistant-actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -87,34 +87,44 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadModels = useCallback(async (options?: { refresh?: boolean; signal?: AbortSignal }) => {
|
||||||
let cancelled = false;
|
if (options?.signal?.aborted) return;
|
||||||
|
|
||||||
async function loadModels() {
|
|
||||||
setModelsLoading(true);
|
setModelsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/agent/models");
|
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");
|
if (!response.ok) throw new Error("Model discovery unavailable");
|
||||||
const payload = (await response.json()) as ModelsResponse;
|
const payload = (await response.json()) as ModelsResponse;
|
||||||
if (cancelled) return;
|
if (options?.signal?.aborted) return;
|
||||||
setModelOptions(payload.models);
|
setModelOptions(payload.models);
|
||||||
setSelectedModel(payload.selectedModel);
|
setSelectedModel(payload.selectedModel);
|
||||||
setFallbackModel(payload.fallbackModel);
|
setFallbackModel(payload.fallbackModel);
|
||||||
setModelsDegraded(payload.degraded);
|
setModelsDegraded(payload.degraded);
|
||||||
} catch {
|
setError(null);
|
||||||
if (cancelled) return;
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
setModelsDegraded(true);
|
setModelsDegraded(true);
|
||||||
|
setError("Model discovery unavailable");
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setModelsLoading(false);
|
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() {
|
function scrollToBottom() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const node = listRef.current;
|
const node = listRef.current;
|
||||||
@@ -271,6 +281,20 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
aria-label="Refresh assistant models"
|
||||||
|
disabled={modelsLoading || savingModel || isPending}
|
||||||
|
onClick={() => void loadModels({ refresh: true })}
|
||||||
|
>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ test("assistant chat smoke after opt-in", async ({ page }) => {
|
|||||||
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
|
||||||
const modelSelector = page.getByRole("combobox", { name: "Assistant model" });
|
const modelSelector = page.getByRole("combobox", { name: "Assistant model" });
|
||||||
await expect(modelSelector).toBeVisible();
|
await expect(modelSelector).toBeVisible();
|
||||||
|
const refreshModels = page.getByRole("button", { name: "Refresh assistant models" });
|
||||||
|
await expect(refreshModels).toBeVisible();
|
||||||
|
await refreshModels.click();
|
||||||
await expect.poll(() => modelSelector.evaluate((node) => node.tagName)).toBe("SELECT");
|
await expect.poll(() => modelSelector.evaluate((node) => node.tagName)).toBe("SELECT");
|
||||||
await expect
|
await expect
|
||||||
.poll(() => modelSelector.evaluate((node) => node.getBoundingClientRect().height))
|
.poll(() => modelSelector.evaluate((node) => node.getBoundingClientRect().height))
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
describe("GET /api/agent/models", () => {
|
||||||
|
it("is dynamic and returns no-store responses", async () => {
|
||||||
|
process.env.DATABASE_URL ??= "postgres://famapp:famapp@localhost:5432/famapp";
|
||||||
|
|
||||||
|
const { GET, dynamic } = await import("../../src/app/api/agent/models/route");
|
||||||
|
|
||||||
|
assert.equal(dynamic, "force-dynamic");
|
||||||
|
|
||||||
|
const response = await GET(new Request("http://localhost/api/agent/models?refresh=1"));
|
||||||
|
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
assert.equal(response.headers.get("Cache-Control"), "no-store");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,16 +88,28 @@ describe("listLlmModels", () => {
|
|||||||
assert.equal(result.degraded, true);
|
assert.equal(result.degraded, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses an unadvertised fallback alias instead of unrelated provider models", async () => {
|
it("fetches the typed uncensored catalog when uncensored is the fallback route", async () => {
|
||||||
|
const requests: Request[] = [];
|
||||||
const result = await listLlmModels({
|
const result = await listLlmModels({
|
||||||
config: { ...openAiConfig, model: "uncensored" },
|
config: { ...openAiConfig, model: "uncensored" },
|
||||||
fetchImpl: async () =>
|
fetchImpl: async (input, init) => {
|
||||||
Response.json({
|
requests.push(new Request(input, init));
|
||||||
data: [{ id: "auto" }, { id: "qwen3:8b" }, { id: "qwen3-coder:30b" }],
|
return Response.json({
|
||||||
}),
|
data: [
|
||||||
|
{ id: "uncensored" },
|
||||||
|
{ id: "gemma4-uncensored:26b" },
|
||||||
|
{ id: "dolphin-mistral:latest" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result.models, [{ id: "uncensored", label: "uncensored" }]);
|
assert.equal(requests[0]?.url, "https://llm.example.test/v1/models?type=uncensored");
|
||||||
|
assert.deepEqual(result.models, [
|
||||||
|
{ id: "dolphin-mistral:latest", label: "dolphin-mistral:latest" },
|
||||||
|
{ id: "gemma4-uncensored:26b", label: "gemma4-uncensored:26b" },
|
||||||
|
{ id: "uncensored", label: "uncensored" },
|
||||||
|
]);
|
||||||
assert.equal(result.fallbackModel, "uncensored");
|
assert.equal(result.fallbackModel, "uncensored");
|
||||||
assert.equal(result.degraded, false);
|
assert.equal(result.degraded, false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user