From 4c82d551ea1f0c8091a3ffc31935a8d22855bbc0 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 8 Jul 2026 16:46:53 -0500 Subject: [PATCH] feat(agent): add assistant model selector --- src/app/layout.tsx | 4 + .../agent/components/assistant-bubble.tsx | 4 +- .../agent/components/assistant-panel.tsx | 134 +++++++++++++++--- tests/e2e/assistant.spec.ts | 1 + 4 files changed, 125 insertions(+), 18 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ef104b1..7da8be5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -104,6 +104,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo let signedIn = false; let assistantEnabled = false; let assistantName = DEFAULT_ASSISTANT_NAME; + let assistantModel: string | null = null; const session = await auth(); if (session?.user?.id) { @@ -117,6 +118,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo themeNavStyle: users.themeNavStyle, assistantEnabled: users.assistantEnabled, assistantName: users.assistantName, + assistantModel: users.assistantModel, }) .from(users) .where(eq(users.id, session.user.id)) @@ -129,6 +131,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo navStyle = row.themeNavStyle as NavStyle; assistantEnabled = row.assistantEnabled; assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME; + assistantModel = row.assistantModel?.trim() || null; } userDashboards = await db .select({ @@ -186,6 +189,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo configured={isLlmConfigured()} userId={session.user.id} assistantName={assistantName} + assistantModel={assistantModel} /> ) : null} diff --git a/src/modules/agent/components/assistant-bubble.tsx b/src/modules/agent/components/assistant-bubble.tsx index 321815f..904722b 100644 --- a/src/modules/agent/components/assistant-bubble.tsx +++ b/src/modules/agent/components/assistant-bubble.tsx @@ -8,9 +8,10 @@ type Props = { configured: boolean; userId: string; assistantName: string; + assistantModel: string | null; }; -export function AssistantBubble({ configured, userId, assistantName }: Props) { +export function AssistantBubble({ configured, userId, assistantName, assistantModel }: Props) { const [open, setOpen] = useState(false); return ( @@ -41,6 +42,7 @@ export function AssistantBubble({ configured, userId, assistantName }: Props) { configured={configured} userId={userId} assistantName={assistantName} + assistantModel={assistantModel} /> ) : null} diff --git a/src/modules/agent/components/assistant-panel.tsx b/src/modules/agent/components/assistant-panel.tsx index 155cf7a..1c70da1 100644 --- a/src/modules/agent/components/assistant-panel.tsx +++ b/src/modules/agent/components/assistant-panel.tsx @@ -1,9 +1,18 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; import { ImagePlus, Loader2, Mic, Send, Square } from "lucide-react"; +import { setAssistantModel } from "@/app/settings/assistant-actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { consumeAgentChatStream } from "../assistant-chat-stream"; import { clearAssistantChat, @@ -18,12 +27,25 @@ type Props = { configured: boolean; userId: string; assistantName: string; + assistantModel: string | null; }; type PendingImage = { url: string; }; +type LlmModelOption = { + id: string; + label: string; +}; + +type ModelsResponse = { + models: LlmModelOption[]; + selectedModel: string; + fallbackModel: string; + degraded: boolean; +}; + async function uploadAssistantImage(file: File): Promise { const formData = new FormData(); formData.append("file", file); @@ -36,7 +58,7 @@ async function uploadAssistantImage(file: File): Promise { return payload.url; } -export function AssistantPanel({ configured, userId, assistantName }: Props) { +export function AssistantPanel({ configured, userId, assistantName, assistantModel }: Props) { const [messages, setMessages] = useState(() => loadAssistantChat(userId)); const [input, setInput] = useState(""); const [pendingImage, setPendingImage] = useState(null); @@ -44,6 +66,12 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) { const [error, setError] = useState(null); const [isPending, setIsPending] = useState(false); const [activityLabel, setActivityLabel] = useState(null); + const [modelOptions, setModelOptions] = useState([]); + const [selectedModel, setSelectedModel] = useState(assistantModel ?? ""); + const [fallbackModel, setFallbackModel] = useState(""); + const [modelsDegraded, setModelsDegraded] = useState(false); + const [modelsLoading, setModelsLoading] = useState(true); + const [savingModel, startTransition] = useTransition(); const listRef = useRef(null); const abortRef = useRef(null); const imageInputRef = useRef(null); @@ -67,6 +95,34 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) { }; }, []); + useEffect(() => { + let cancelled = false; + + 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); + } + } + + void loadModels(); + return () => { + cancelled = true; + }; + }, []); + function scrollToBottom() { requestAnimationFrame(() => { const node = listRef.current; @@ -102,6 +158,21 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) { } } + function changeModel(nextModel: string | null) { + if (!nextModel) return; + + setSelectedModel(nextModel); + setError(null); + + startTransition(async () => { + try { + await setAssistantModel(nextModel === fallbackModel ? null : nextModel); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not save assistant model"); + } + }); + } + async function sendMessage() { const text = input.trim(); const hasImage = pendingImage !== null; @@ -133,6 +204,7 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: nextMessages.map(toClientChatMessage), + model: selectedModel || undefined, stream: true, }), signal: controller.signal, @@ -175,21 +247,49 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) { return (
-

- {configured - ? "Type, talk, or send a photo — I can update lists, calendar, notes, and more." - : "Mock provider active — set LLM_BASE_URL for your homelab model."} -

- {messages.length > 0 ? ( - - ) : null} +
+

+ {configured + ? "Type, talk, or send a photo — I can update lists, calendar, notes, and more." + : "Mock provider active — set LLM_BASE_URL for your homelab model."} +

+ {modelsDegraded ? ( +

Model discovery unavailable; using fallback.

+ ) : null} +
+
+ {modelOptions.length > 0 ? ( + + ) : null} + {messages.length > 0 ? ( + + ) : null} +
{ await page.goto("/"); await page.getByRole("button", { name: "Open assistant" }).click(); await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible(); + await expect(page.getByRole("combobox", { name: "Assistant model" })).toBeVisible(); await page.getByLabel("Message for Assistant").fill("hello assistant"); await page.getByRole("button", { name: "Send" }).click();