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 signedIn = false;
|
||||||
let assistantEnabled = false;
|
let assistantEnabled = false;
|
||||||
let assistantName = DEFAULT_ASSISTANT_NAME;
|
let assistantName = DEFAULT_ASSISTANT_NAME;
|
||||||
|
let assistantModel: string | null = null;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
@@ -117,6 +118,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
themeNavStyle: users.themeNavStyle,
|
themeNavStyle: users.themeNavStyle,
|
||||||
assistantEnabled: users.assistantEnabled,
|
assistantEnabled: users.assistantEnabled,
|
||||||
assistantName: users.assistantName,
|
assistantName: users.assistantName,
|
||||||
|
assistantModel: users.assistantModel,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, session.user.id))
|
.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;
|
navStyle = row.themeNavStyle as NavStyle;
|
||||||
assistantEnabled = row.assistantEnabled;
|
assistantEnabled = row.assistantEnabled;
|
||||||
assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME;
|
assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME;
|
||||||
|
assistantModel = row.assistantModel?.trim() || null;
|
||||||
}
|
}
|
||||||
userDashboards = await db
|
userDashboards = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -186,6 +189,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
configured={isLlmConfigured()}
|
configured={isLlmConfigured()}
|
||||||
userId={session.user.id}
|
userId={session.user.id}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
|
assistantModel={assistantModel}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AppToaster position="bottom-right" />
|
<AppToaster position="bottom-right" />
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ type Props = {
|
|||||||
configured: boolean;
|
configured: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
assistantName: 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);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +42,7 @@ export function AssistantBubble({ configured, userId, assistantName }: Props) {
|
|||||||
configured={configured}
|
configured={configured}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
|
assistantModel={assistantModel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
"use client";
|
"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 { ImagePlus, Loader2, Mic, Send, Square } from "lucide-react";
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { consumeAgentChatStream } from "../assistant-chat-stream";
|
import { consumeAgentChatStream } from "../assistant-chat-stream";
|
||||||
import {
|
import {
|
||||||
clearAssistantChat,
|
clearAssistantChat,
|
||||||
@@ -18,12 +27,25 @@ type Props = {
|
|||||||
configured: boolean;
|
configured: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
|
assistantModel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingImage = {
|
type PendingImage = {
|
||||||
url: string;
|
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> {
|
async function uploadAssistantImage(file: File): Promise<string> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
@@ -36,7 +58,7 @@ async function uploadAssistantImage(file: File): Promise<string> {
|
|||||||
return payload.url;
|
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 [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [pendingImage, setPendingImage] = useState<PendingImage | null>(null);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [activityLabel, setActivityLabel] = useState<string | null>(null);
|
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 listRef = useRef<HTMLDivElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(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() {
|
function scrollToBottom() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const node = listRef.current;
|
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() {
|
async function sendMessage() {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
const hasImage = pendingImage !== null;
|
const hasImage = pendingImage !== null;
|
||||||
@@ -133,6 +204,7 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: nextMessages.map(toClientChatMessage),
|
messages: nextMessages.map(toClientChatMessage),
|
||||||
|
model: selectedModel || undefined,
|
||||||
stream: true,
|
stream: true,
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -175,11 +247,38 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="muted min-w-0 text-[12px] leading-relaxed">
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="muted text-[12px] leading-relaxed">
|
||||||
{configured
|
{configured
|
||||||
? "Type, talk, or send a photo — I can update lists, calendar, notes, and more."
|
? "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."}
|
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
|
||||||
</p>
|
</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 ? (
|
{messages.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -191,6 +290,7 @@ export function AssistantPanel({ configured, userId, assistantName }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ test("assistant chat smoke after opt-in", async ({ page }) => {
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("button", { name: "Open assistant" }).click();
|
await page.getByRole("button", { name: "Open assistant" }).click();
|
||||||
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
|
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.getByLabel("Message for Assistant").fill("hello assistant");
|
||||||
await page.getByRole("button", { name: "Send" }).click();
|
await page.getByRole("button", { name: "Send" }).click();
|
||||||
|
|||||||
Reference in New Issue
Block a user