feat(agent): add assistant model selector
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user