feat(agent): let users choose model route
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "assistant_model_route" text;--> statement-breakpoint
|
||||||
|
UPDATE "users"
|
||||||
|
SET "assistant_model_route" = "assistant_model",
|
||||||
|
"assistant_model" = NULL
|
||||||
|
WHERE "assistant_model" IN ('auto', 'uncensored');
|
||||||
@@ -183,6 +183,13 @@
|
|||||||
"when": 1783560000000,
|
"when": 1783560000000,
|
||||||
"tag": "0025_assistant_model",
|
"tag": "0025_assistant_model",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783561000000,
|
||||||
|
"tag": "0026_assistant_model_route",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function POST(request: Request) {
|
|||||||
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
|
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelList = await listLlmModels();
|
const modelList = await listLlmModels({ route: assistant.modelRoute });
|
||||||
const modelResolution = resolveAssistantModel({
|
const modelResolution = resolveAssistantModel({
|
||||||
requestedModel: parsed.data.model,
|
requestedModel: parsed.data.model,
|
||||||
savedModel: assistant.model,
|
savedModel: assistant.model,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiError, apiJson } from "@/lib/api-handler";
|
import { apiError, apiJson } from "@/lib/api-handler";
|
||||||
import { resolveApiAuth } from "@/lib/api-auth";
|
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 { isValidAssistantModelRoute, listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,10 +21,16 @@ export async function GET(request: Request) {
|
|||||||
return noStore(apiError("Assistant not enabled", 403));
|
return noStore(apiError("Assistant not enabled", 403));
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelList = await listLlmModels();
|
const requestedRoute = new URL(request.url).searchParams.get("route");
|
||||||
|
if (requestedRoute !== null && !isValidAssistantModelRoute(requestedRoute)) {
|
||||||
|
return noStore(apiError("Invalid assistant model route", 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelRoute = requestedRoute ?? assistant.modelRoute;
|
||||||
|
const modelList = await listLlmModels({ route: modelRoute });
|
||||||
const resolved = resolveAssistantModel({
|
const resolved = resolveAssistantModel({
|
||||||
requestedModel: null,
|
requestedModel: null,
|
||||||
savedModel: assistant.model,
|
savedModel: requestedRoute === null ? assistant.model : null,
|
||||||
fallbackModel: modelList.fallbackModel,
|
fallbackModel: modelList.fallbackModel,
|
||||||
models: modelList.models,
|
models: modelList.models,
|
||||||
});
|
});
|
||||||
@@ -34,6 +40,7 @@ export async function GET(request: Request) {
|
|||||||
models: modelList.models,
|
models: modelList.models,
|
||||||
selectedModel: resolved.model,
|
selectedModel: resolved.model,
|
||||||
fallbackModel: modelList.fallbackModel,
|
fallbackModel: modelList.fallbackModel,
|
||||||
|
route: modelList.route,
|
||||||
degraded: modelList.degraded,
|
degraded: modelList.degraded,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 assistantModelRoute: string | null = null;
|
||||||
let assistantModel: string | null = null;
|
let assistantModel: string | null = null;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -118,6 +119,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,
|
||||||
|
assistantModelRoute: users.assistantModelRoute,
|
||||||
assistantModel: users.assistantModel,
|
assistantModel: users.assistantModel,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -131,6 +133,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;
|
||||||
|
assistantModelRoute = row.assistantModelRoute?.trim() || null;
|
||||||
assistantModel = row.assistantModel?.trim() || null;
|
assistantModel = row.assistantModel?.trim() || null;
|
||||||
}
|
}
|
||||||
userDashboards = await db
|
userDashboards = await db
|
||||||
@@ -189,6 +192,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}
|
||||||
|
assistantModelRoute={assistantModelRoute}
|
||||||
assistantModel={assistantModel}
|
assistantModel={assistantModel}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import {
|
|||||||
MAX_ASSISTANT_NAME_LENGTH,
|
MAX_ASSISTANT_NAME_LENGTH,
|
||||||
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
|
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
|
||||||
} from "@/lib/assistant-config";
|
} from "@/lib/assistant-config";
|
||||||
import { isValidLlmModelId, listLlmModels } from "@/lib/llm/models";
|
import {
|
||||||
|
isValidAssistantModelRoute,
|
||||||
|
isValidLlmModelId,
|
||||||
|
listLlmModels,
|
||||||
|
type AssistantModelRoute,
|
||||||
|
} from "@/lib/llm/models";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { getAssistantPreferences } from "@/lib/assistant-preference";
|
||||||
|
|
||||||
const assistantNameSchema = z
|
const assistantNameSchema = z
|
||||||
.string()
|
.string()
|
||||||
@@ -51,6 +57,19 @@ export async function setAssistantSystemPrompt(prompt: string | null): Promise<v
|
|||||||
revalidateAssistantSurfaces();
|
revalidateAssistantSurfaces();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setAssistantModelRoute(route: AssistantModelRoute): Promise<void> {
|
||||||
|
if (!isValidAssistantModelRoute(route)) {
|
||||||
|
throw new Error("Invalid assistant model route");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ assistantModelRoute: route, assistantModel: null })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
revalidateAssistantSurfaces();
|
||||||
|
}
|
||||||
|
|
||||||
export async function setAssistantModel(model: string | null): Promise<void> {
|
export async function setAssistantModel(model: string | null): Promise<void> {
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
const normalized = model?.trim() || null;
|
const normalized = model?.trim() || null;
|
||||||
@@ -59,7 +78,8 @@ export async function setAssistantModel(model: string | null): Promise<void> {
|
|||||||
throw new Error("Invalid assistant model");
|
throw new Error("Invalid assistant model");
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = await listLlmModels();
|
const assistant = await getAssistantPreferences(user.id);
|
||||||
|
const available = await listLlmModels({ route: assistant.modelRoute });
|
||||||
const requested = normalized === available.fallbackModel ? null : normalized;
|
const requested = normalized === available.fallbackModel ? null : normalized;
|
||||||
|
|
||||||
if (requested !== null && !available.models.some((option) => option.id === requested)) {
|
if (requested !== null && !available.models.some((option) => option.id === requested)) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
|
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
|
||||||
|
import { isValidAssistantModelRoute, type AssistantModelRoute } from "@/lib/llm/models";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -14,6 +15,7 @@ export type AssistantPreferences = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
systemPrompt: string | null;
|
systemPrompt: string | null;
|
||||||
|
modelRoute: AssistantModelRoute | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export async function getAssistantPreferences(userId: string): Promise<Assistant
|
|||||||
assistantEnabled: users.assistantEnabled,
|
assistantEnabled: users.assistantEnabled,
|
||||||
assistantName: users.assistantName,
|
assistantName: users.assistantName,
|
||||||
assistantSystemPrompt: users.assistantSystemPrompt,
|
assistantSystemPrompt: users.assistantSystemPrompt,
|
||||||
|
assistantModelRoute: users.assistantModelRoute,
|
||||||
assistantModel: users.assistantModel,
|
assistantModel: users.assistantModel,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -33,6 +36,10 @@ export async function getAssistantPreferences(userId: string): Promise<Assistant
|
|||||||
enabled: row?.assistantEnabled ?? false,
|
enabled: row?.assistantEnabled ?? false,
|
||||||
name: row?.assistantName?.trim() || DEFAULT_ASSISTANT_NAME,
|
name: row?.assistantName?.trim() || DEFAULT_ASSISTANT_NAME,
|
||||||
systemPrompt: row?.assistantSystemPrompt ?? null,
|
systemPrompt: row?.assistantSystemPrompt ?? null,
|
||||||
|
modelRoute:
|
||||||
|
row?.assistantModelRoute && isValidAssistantModelRoute(row.assistantModelRoute)
|
||||||
|
? row.assistantModelRoute
|
||||||
|
: null,
|
||||||
model: row?.assistantModel?.trim() || null,
|
model: row?.assistantModel?.trim() || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-7
@@ -5,9 +5,14 @@ export type LlmModelOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ASSISTANT_MODEL_ROUTES = ["auto", "uncensored"] as const;
|
||||||
|
|
||||||
|
export type AssistantModelRoute = (typeof ASSISTANT_MODEL_ROUTES)[number];
|
||||||
|
|
||||||
export type LlmModelsResult = {
|
export type LlmModelsResult = {
|
||||||
models: LlmModelOption[];
|
models: LlmModelOption[];
|
||||||
fallbackModel: string;
|
fallbackModel: string;
|
||||||
|
route: AssistantModelRoute;
|
||||||
degraded: boolean;
|
degraded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +33,22 @@ export function isValidLlmModelId(value: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidAssistantModelRoute(value: string): value is AssistantModelRoute {
|
||||||
|
return ASSISTANT_MODEL_ROUTES.some((route) => route === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelRoute(
|
||||||
|
route: AssistantModelRoute | null | undefined,
|
||||||
|
config: LlmConfig,
|
||||||
|
): { route: AssistantModelRoute; fallbackModel: string } {
|
||||||
|
if (route) {
|
||||||
|
return { route, fallbackModel: route };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRoute: AssistantModelRoute = config.model === "uncensored" ? "uncensored" : "auto";
|
||||||
|
return { route: defaultRoute, fallbackModel: config.model };
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] {
|
export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] {
|
||||||
const data =
|
const data =
|
||||||
typeof payload === "object" && payload !== null && "data" in payload
|
typeof payload === "object" && payload !== null && "data" in payload
|
||||||
@@ -51,14 +72,15 @@ export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] {
|
|||||||
export async function listLlmModels(options?: {
|
export async function listLlmModels(options?: {
|
||||||
config?: LlmConfig;
|
config?: LlmConfig;
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
|
route?: AssistantModelRoute | null;
|
||||||
}): Promise<LlmModelsResult> {
|
}): Promise<LlmModelsResult> {
|
||||||
const config = options?.config ?? getLlmConfig();
|
const config = options?.config ?? getLlmConfig();
|
||||||
const fetchImpl = options?.fetchImpl ?? fetch;
|
const fetchImpl = options?.fetchImpl ?? fetch;
|
||||||
const fallbackModel = config.model;
|
const { route, fallbackModel } = resolveModelRoute(options?.route, config);
|
||||||
const fallbackOption = { id: fallbackModel, label: fallbackModel };
|
const fallbackOption = { id: fallbackModel, label: fallbackModel };
|
||||||
|
|
||||||
if (config.provider === "mock" || !config.baseUrl) {
|
if (config.provider === "mock" || !config.baseUrl) {
|
||||||
return { models: [fallbackOption], fallbackModel, degraded: false };
|
return { models: [fallbackOption], fallbackModel, route, degraded: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -66,7 +88,7 @@ export async function listLlmModels(options?: {
|
|||||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||||
|
|
||||||
const modelsUrl = new URL(`${config.baseUrl.replace(/\/$/, "")}/models`);
|
const modelsUrl = new URL(`${config.baseUrl.replace(/\/$/, "")}/models`);
|
||||||
if (fallbackModel === "uncensored") {
|
if (route === "uncensored") {
|
||||||
modelsUrl.searchParams.set("type", "uncensored");
|
modelsUrl.searchParams.set("type", "uncensored");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,26 +98,27 @@ export async function listLlmModels(options?: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return { models: [fallbackOption], fallbackModel, degraded: true };
|
return { models: [fallbackOption], fallbackModel, route, degraded: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = normalizeLlmModelsPayload(await response.json());
|
const models = normalizeLlmModelsPayload(await response.json());
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
return { models: [fallbackOption], fallbackModel, degraded: true };
|
return { models: [fallbackOption], fallbackModel, route, degraded: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!models.some((model) => model.id === fallbackModel)) {
|
if (!models.some((model) => model.id === fallbackModel)) {
|
||||||
return { models: [fallbackOption], fallbackModel, degraded: false };
|
return { models: [fallbackOption], fallbackModel, route, degraded: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
models,
|
models,
|
||||||
fallbackModel,
|
fallbackModel,
|
||||||
|
route,
|
||||||
degraded: false,
|
degraded: false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { models: [fallbackOption], fallbackModel, degraded: true };
|
return { models: [fallbackOption], fallbackModel, route, degraded: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const users = pgTable("users", {
|
|||||||
assistantEnabled: boolean("assistant_enabled").notNull().default(false),
|
assistantEnabled: boolean("assistant_enabled").notNull().default(false),
|
||||||
assistantName: text("assistant_name").notNull().default("Assistant"),
|
assistantName: text("assistant_name").notNull().default("Assistant"),
|
||||||
assistantSystemPrompt: text("assistant_system_prompt"),
|
assistantSystemPrompt: text("assistant_system_prompt"),
|
||||||
|
assistantModelRoute: text("assistant_model_route"),
|
||||||
assistantModel: text("assistant_model"),
|
assistantModel: text("assistant_model"),
|
||||||
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
|
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ type Props = {
|
|||||||
configured: boolean;
|
configured: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
|
assistantModelRoute: string | null;
|
||||||
assistantModel: string | null;
|
assistantModel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssistantBubble({ configured, userId, assistantName, assistantModel }: Props) {
|
export function AssistantBubble({
|
||||||
|
configured,
|
||||||
|
userId,
|
||||||
|
assistantName,
|
||||||
|
assistantModelRoute,
|
||||||
|
assistantModel,
|
||||||
|
}: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +49,7 @@ export function AssistantBubble({ configured, userId, assistantName, assistantMo
|
|||||||
configured={configured}
|
configured={configured}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
|
assistantModelRoute={assistantModelRoute}
|
||||||
assistantModel={assistantModel}
|
assistantModel={assistantModel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { ChevronDown, ImagePlus, Loader2, Mic, RefreshCw, 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, setAssistantModelRoute } 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 type { AssistantModelRoute } from "@/lib/llm/models";
|
||||||
import { consumeAgentChatStream } from "../assistant-chat-stream";
|
import { consumeAgentChatStream } from "../assistant-chat-stream";
|
||||||
import {
|
import {
|
||||||
clearAssistantChat,
|
clearAssistantChat,
|
||||||
@@ -19,6 +20,7 @@ type Props = {
|
|||||||
configured: boolean;
|
configured: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
|
assistantModelRoute: string | null;
|
||||||
assistantModel: string | null;
|
assistantModel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ type ModelsResponse = {
|
|||||||
models: LlmModelOption[];
|
models: LlmModelOption[];
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
fallbackModel: string;
|
fallbackModel: string;
|
||||||
|
route: AssistantModelRoute;
|
||||||
degraded: boolean;
|
degraded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,7 +53,17 @@ async function uploadAssistantImage(file: File): Promise<string> {
|
|||||||
return payload.url;
|
return payload.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantPanel({ configured, userId, assistantName, assistantModel }: Props) {
|
function normalizeAssistantModelRoute(value: string | null): AssistantModelRoute {
|
||||||
|
return value === "uncensored" ? "uncensored" : "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssistantPanel({
|
||||||
|
configured,
|
||||||
|
userId,
|
||||||
|
assistantName,
|
||||||
|
assistantModelRoute,
|
||||||
|
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);
|
||||||
@@ -59,6 +72,9 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
|
|||||||
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 [modelOptions, setModelOptions] = useState<LlmModelOption[]>([]);
|
||||||
|
const [selectedRoute, setSelectedRoute] = useState<AssistantModelRoute>(() =>
|
||||||
|
normalizeAssistantModelRoute(assistantModelRoute),
|
||||||
|
);
|
||||||
const [selectedModel, setSelectedModel] = useState(assistantModel ?? "");
|
const [selectedModel, setSelectedModel] = useState(assistantModel ?? "");
|
||||||
const [fallbackModel, setFallbackModel] = useState("");
|
const [fallbackModel, setFallbackModel] = useState("");
|
||||||
const [modelsDegraded, setModelsDegraded] = useState(false);
|
const [modelsDegraded, setModelsDegraded] = useState(false);
|
||||||
@@ -87,31 +103,40 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadModels = useCallback(async (options?: { refresh?: boolean; signal?: AbortSignal }) => {
|
const loadModels = useCallback(
|
||||||
if (options?.signal?.aborted) return;
|
async (options?: { refresh?: boolean; route?: AssistantModelRoute; signal?: AbortSignal }) => {
|
||||||
|
|
||||||
setModelsLoading(true);
|
|
||||||
try {
|
|
||||||
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");
|
|
||||||
const payload = (await response.json()) as ModelsResponse;
|
|
||||||
if (options?.signal?.aborted) return;
|
if (options?.signal?.aborted) return;
|
||||||
setModelOptions(payload.models);
|
|
||||||
setSelectedModel(payload.selectedModel);
|
setModelsLoading(true);
|
||||||
setFallbackModel(payload.fallbackModel);
|
try {
|
||||||
setModelsDegraded(payload.degraded);
|
const params = new URLSearchParams();
|
||||||
setError(null);
|
if (options?.refresh) params.set("refresh", "1");
|
||||||
} catch (err) {
|
if (options?.route) params.set("route", options.route);
|
||||||
if (err instanceof Error && err.name === "AbortError") return;
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
setModelsDegraded(true);
|
|
||||||
setError("Model discovery unavailable");
|
const response = await fetch(`/api/agent/models${query}`, {
|
||||||
} finally {
|
cache: "no-store",
|
||||||
if (!options?.signal?.aborted) setModelsLoading(false);
|
signal: options?.signal,
|
||||||
}
|
});
|
||||||
}, []);
|
if (!response.ok) throw new Error("Model discovery unavailable");
|
||||||
|
const payload = (await response.json()) as ModelsResponse;
|
||||||
|
if (options?.signal?.aborted) return;
|
||||||
|
setModelOptions(payload.models);
|
||||||
|
setSelectedRoute(payload.route);
|
||||||
|
setSelectedModel(payload.selectedModel);
|
||||||
|
setFallbackModel(payload.fallbackModel);
|
||||||
|
setModelsDegraded(payload.degraded);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
|
setModelsDegraded(true);
|
||||||
|
setError("Model discovery unavailable");
|
||||||
|
} finally {
|
||||||
|
if (!options?.signal?.aborted) setModelsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -175,6 +200,26 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeModelRoute(nextRoute: string) {
|
||||||
|
const route = normalizeAssistantModelRoute(nextRoute);
|
||||||
|
|
||||||
|
setSelectedRoute(route);
|
||||||
|
setSelectedModel(route);
|
||||||
|
setFallbackModel(route);
|
||||||
|
setModelOptions([]);
|
||||||
|
setModelsDegraded(false);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await setAssistantModelRoute(route);
|
||||||
|
await loadModels({ route, refresh: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not save assistant route");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
const hasImage = pendingImage !== null;
|
const hasImage = pendingImage !== null;
|
||||||
@@ -242,6 +287,7 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
|
|||||||
const inputDisabled = isPending || voiceState === "transcribing" || uploadingImage;
|
const inputDisabled = isPending || voiceState === "transcribing" || uploadingImage;
|
||||||
const canSend =
|
const canSend =
|
||||||
!isPending &&
|
!isPending &&
|
||||||
|
!savingModel &&
|
||||||
voiceState === "idle" &&
|
voiceState === "idle" &&
|
||||||
!uploadingImage &&
|
!uploadingImage &&
|
||||||
(input.trim().length > 0 || pendingImage !== null);
|
(input.trim().length > 0 || pendingImage !== null);
|
||||||
@@ -260,6 +306,22 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<div className="relative max-w-32">
|
||||||
|
<select
|
||||||
|
aria-label="Assistant model route"
|
||||||
|
value={selectedRoute}
|
||||||
|
onChange={(event) => changeModelRoute(event.target.value)}
|
||||||
|
disabled={modelsLoading || savingModel || isPending}
|
||||||
|
className="h-9 w-full max-w-32 appearance-none truncate rounded-[min(var(--radius-md),10px)] border border-input bg-[var(--card)] py-0 pr-8 pl-3 text-[13px] leading-9 text-[var(--ink)] outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="uncensored">Uncensored</option>
|
||||||
|
</select>
|
||||||
|
<ChevronDown
|
||||||
|
className="pointer-events-none absolute top-1/2 right-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{modelOptions.length > 0 ? (
|
{modelOptions.length > 0 ? (
|
||||||
<div className="relative max-w-36">
|
<div className="relative max-w-36">
|
||||||
<select
|
<select
|
||||||
@@ -287,7 +349,7 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
aria-label="Refresh assistant models"
|
aria-label="Refresh assistant models"
|
||||||
disabled={modelsLoading || savingModel || isPending}
|
disabled={modelsLoading || savingModel || isPending}
|
||||||
onClick={() => void loadModels({ refresh: true })}
|
onClick={() => void loadModels({ route: selectedRoute, refresh: true })}
|
||||||
>
|
>
|
||||||
{modelsLoading ? (
|
{modelsLoading ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ 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();
|
||||||
const modelSelector = page.getByRole("combobox", { name: "Assistant model" });
|
const routeSelector = page.getByRole("combobox", { name: "Assistant model route" });
|
||||||
|
await expect(routeSelector).toBeVisible();
|
||||||
|
await routeSelector.selectOption("auto");
|
||||||
|
await expect(routeSelector).toHaveValue("auto");
|
||||||
|
await expect(routeSelector).toBeEnabled();
|
||||||
|
await routeSelector.selectOption("uncensored");
|
||||||
|
await expect(routeSelector).toHaveValue("uncensored");
|
||||||
|
await expect(routeSelector).toBeEnabled();
|
||||||
|
const modelSelector = page.getByRole("combobox", { name: "Assistant model", exact: true });
|
||||||
await expect(modelSelector).toBeVisible();
|
await expect(modelSelector).toBeVisible();
|
||||||
const refreshModels = page.getByRole("button", { name: "Refresh assistant models" });
|
const refreshModels = page.getByRole("button", { name: "Refresh assistant models" });
|
||||||
await expect(refreshModels).toBeVisible();
|
await expect(refreshModels).toBeVisible();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import {
|
import {
|
||||||
|
isValidAssistantModelRoute,
|
||||||
isValidLlmModelId,
|
isValidLlmModelId,
|
||||||
listLlmModels,
|
listLlmModels,
|
||||||
normalizeLlmModelsPayload,
|
normalizeLlmModelsPayload,
|
||||||
@@ -56,6 +57,18 @@ describe("isValidLlmModelId", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isValidAssistantModelRoute", () => {
|
||||||
|
it("accepts the supported route families", () => {
|
||||||
|
assert.equal(isValidAssistantModelRoute("auto"), true);
|
||||||
|
assert.equal(isValidAssistantModelRoute("uncensored"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported or padded route families", () => {
|
||||||
|
assert.equal(isValidAssistantModelRoute("bogus"), false);
|
||||||
|
assert.equal(isValidAssistantModelRoute(" uncensored "), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("listLlmModels", () => {
|
describe("listLlmModels", () => {
|
||||||
it("fetches provider models with API key auth when the fallback is advertised", async () => {
|
it("fetches provider models with API key auth when the fallback is advertised", async () => {
|
||||||
const requests: Request[] = [];
|
const requests: Request[] = [];
|
||||||
@@ -88,10 +101,11 @@ describe("listLlmModels", () => {
|
|||||||
assert.equal(result.degraded, true);
|
assert.equal(result.degraded, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches the typed uncensored catalog when uncensored is the fallback route", async () => {
|
it("fetches the typed uncensored catalog when uncensored is the selected route", async () => {
|
||||||
const requests: Request[] = [];
|
const requests: Request[] = [];
|
||||||
const result = await listLlmModels({
|
const result = await listLlmModels({
|
||||||
config: { ...openAiConfig, model: "uncensored" },
|
config: { ...openAiConfig, model: "auto" },
|
||||||
|
route: "uncensored",
|
||||||
fetchImpl: async (input, init) => {
|
fetchImpl: async (input, init) => {
|
||||||
requests.push(new Request(input, init));
|
requests.push(new Request(input, init));
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@@ -111,6 +125,30 @@ describe("listLlmModels", () => {
|
|||||||
{ id: "uncensored", label: "uncensored" },
|
{ id: "uncensored", label: "uncensored" },
|
||||||
]);
|
]);
|
||||||
assert.equal(result.fallbackModel, "uncensored");
|
assert.equal(result.fallbackModel, "uncensored");
|
||||||
|
assert.equal(result.route, "uncensored");
|
||||||
|
assert.equal(result.degraded, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches the default catalog when auto is selected over an uncensored deployment default", async () => {
|
||||||
|
const requests: Request[] = [];
|
||||||
|
const result = await listLlmModels({
|
||||||
|
config: { ...openAiConfig, model: "uncensored" },
|
||||||
|
route: "auto",
|
||||||
|
fetchImpl: async (input, init) => {
|
||||||
|
requests.push(new Request(input, init));
|
||||||
|
return Response.json({
|
||||||
|
data: [{ id: "auto" }, { id: "qwen3:8b" }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(requests[0]?.url, "https://llm.example.test/v1/models");
|
||||||
|
assert.deepEqual(result.models, [
|
||||||
|
{ id: "auto", label: "auto" },
|
||||||
|
{ id: "qwen3:8b", label: "qwen3:8b" },
|
||||||
|
]);
|
||||||
|
assert.equal(result.fallbackModel, "auto");
|
||||||
|
assert.equal(result.route, "auto");
|
||||||
assert.equal(result.degraded, false);
|
assert.equal(result.degraded, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user