feat(agent): add assistant model selector

This commit is contained in:
ginnoir
2026-07-08 16:46:53 -05:00
parent 27db599444
commit 4c82d551ea
4 changed files with 125 additions and 18 deletions
+4
View File
@@ -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}
<AppToaster position="bottom-right" />
@@ -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}
/>
</div>
) : null}
+117 -17
View File
@@ -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<string> {
const formData = new FormData();
formData.append("file", file);
@@ -36,7 +58,7 @@ async function uploadAssistantImage(file: File): Promise<string> {
return payload.url;
}
export function AssistantPanel({ configured, userId, assistantName }: Props) {
export function AssistantPanel({ configured, userId, assistantName, assistantModel }: Props) {
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
const [input, setInput] = useState("");
const [pendingImage, setPendingImage] = useState<PendingImage | null>(null);
@@ -44,6 +66,12 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) {
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [activityLabel, setActivityLabel] = useState<string | null>(null);
const [modelOptions, setModelOptions] = useState<LlmModelOption[]>([]);
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<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const imageInputRef = useRef<HTMLInputElement>(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 (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<p className="muted min-w-0 text-[12px] leading-relaxed">
{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."}
</p>
{messages.length > 0 ? (
<button
type="button"
onClick={clearChat}
disabled={isPending}
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Clear
</button>
) : null}
<div className="min-w-0 flex-1">
<p className="muted text-[12px] leading-relaxed">
{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."}
</p>
{modelsDegraded ? (
<p className="muted mt-1 text-[11px]">Model discovery unavailable; using fallback.</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{modelOptions.length > 0 ? (
<Select
items={modelOptions.map((model) => ({ value: model.id, label: model.label }))}
value={selectedModel}
onValueChange={changeModel}
disabled={modelsLoading || savingModel || isPending}
>
<SelectTrigger size="sm" className="max-w-36" aria-label="Assistant model">
<SelectValue>{selectedModel || "Model"}</SelectValue>
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{modelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : null}
{messages.length > 0 ? (
<button
type="button"
onClick={clearChat}
disabled={isPending}
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Clear
</button>
) : null}
</div>
</div>
<div
+1
View File
@@ -15,6 +15,7 @@ test("assistant chat smoke after opt-in", async ({ page }) => {
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();