mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: allow user-configurable AI model selection with server-side validation
This commit is contained in:
@@ -7,6 +7,7 @@ import { env } from "hono/adapter";
|
|||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger as honoLogger } from "hono/logger";
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { createContext } from "./lib/context";
|
import { createContext } from "./lib/context";
|
||||||
|
import { sanitizeModel } from "./lib/ai-models";
|
||||||
import { logger } from "./lib/logger";
|
import { logger } from "./lib/logger";
|
||||||
import { appRouter } from "./routers/index";
|
import { appRouter } from "./routers/index";
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ app.use(
|
|||||||
cors({
|
cors({
|
||||||
origin: process.env.CORS_ORIGIN || "",
|
origin: process.env.CORS_ORIGIN || "",
|
||||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||||
allowHeaders: ["Content-Type", "Authorization"],
|
allowHeaders: ["Content-Type", "Authorization", "x-ai-model"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -91,8 +92,9 @@ app.post("/ai/ocr", async (c) => {
|
|||||||
const base64 = arrayBufferToBase64(await blob.arrayBuffer());
|
const base64 = arrayBufferToBase64(await blob.arrayBuffer());
|
||||||
const dataUrl = `data:${contentType};base64,${base64}`;
|
const dataUrl = `data:${contentType};base64,${base64}`;
|
||||||
|
|
||||||
|
const model = sanitizeModel(c.req.header("x-ai-model"));
|
||||||
const body = {
|
const body = {
|
||||||
model: "openrouter/sonoma-sky-alpha",
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
@@ -161,8 +163,9 @@ app.post("/ai/generate", async (c) => {
|
|||||||
return c.json({ error: "Missing prompt" }, 400);
|
return c.json({ error: "Missing prompt" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelGen = sanitizeModel(c.req.header("x-ai-model"));
|
||||||
const body = {
|
const body = {
|
||||||
model: "openrouter/sonoma-sky-alpha",
|
model: modelGen,
|
||||||
temperature,
|
temperature,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: system },
|
{ role: "system", content: system },
|
||||||
|
|||||||
19
apps/server/src/lib/ai-models.ts
Normal file
19
apps/server/src/lib/ai-models.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export const ALLOWED_MODELS: readonly string[] = [
|
||||||
|
"openrouter/sonoma-sky-alpha",
|
||||||
|
"deepseek/deepseek-chat-v3.1:free",
|
||||||
|
"openrouter/sonoma-dusk-alpha",
|
||||||
|
"deepseek/deepseek-chat-v3-0324:free",
|
||||||
|
"mistralai/mistral-small-3.2-24b-instruct:free",
|
||||||
|
"meta-llama/llama-4-maverick:free",
|
||||||
|
"qwen/qwen2.5-vl-72b-instruct:free"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Default model to fall back to when input is invalid or missing
|
||||||
|
export const DEFAULT_MODEL = ALLOWED_MODELS[0]
|
||||||
|
|
||||||
|
export function sanitizeModel(input?: string | null): string {
|
||||||
|
const candidate = (input || "").trim();
|
||||||
|
if (!candidate) return DEFAULT_MODEL;
|
||||||
|
if (ALLOWED_MODELS.includes(candidate)) return candidate;
|
||||||
|
return DEFAULT_MODEL;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context as HonoContext } from "hono";
|
import type { Context as HonoContext } from "hono";
|
||||||
import { Account, Client } from "node-appwrite";
|
import { Account, Client } from "node-appwrite";
|
||||||
|
import { sanitizeModel } from "./ai-models";
|
||||||
|
|
||||||
// Hoisted regex for performance and linting
|
// Hoisted regex for performance and linting
|
||||||
const BEARER_REGEX = /^Bearer\s+(.+)$/i;
|
const BEARER_REGEX = /^Bearer\s+(.+)$/i;
|
||||||
@@ -16,12 +17,16 @@ export type CreateContextOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function createContext({ context }: CreateContextOptions) {
|
export async function createContext({ context }: CreateContextOptions) {
|
||||||
|
// Capture selected AI model from client header (optional)
|
||||||
|
const aiModelHeader = context.req.header("x-ai-model");
|
||||||
|
const aiModel = sanitizeModel(aiModelHeader);
|
||||||
|
|
||||||
const endpoint = process.env.APPWRITE_ENDPOINT;
|
const endpoint = process.env.APPWRITE_ENDPOINT;
|
||||||
const projectId = process.env.APPWRITE_PROJECT_ID;
|
const projectId = process.env.APPWRITE_PROJECT_ID;
|
||||||
|
|
||||||
if (!(endpoint && projectId)) {
|
if (!(endpoint && projectId)) {
|
||||||
// Appwrite not configured; treat as unauthenticated
|
// Appwrite not configured; treat as unauthenticated
|
||||||
return { user: null as AuthUser };
|
return { user: null as AuthUser, aiModel };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize client per request
|
// Initialize client per request
|
||||||
@@ -68,9 +73,9 @@ export async function createContext({ context }: CreateContextOptions) {
|
|||||||
name: user.name ?? null,
|
name: user.name ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
};
|
};
|
||||||
return { user: minimal };
|
return { user: minimal, aiModel };
|
||||||
} catch {
|
} catch {
|
||||||
return { user: null as AuthUser };
|
return { user: null as AuthUser, aiModel };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { protectedProcedure, publicProcedure, router } from "../lib/trpc";
|
import { protectedProcedure, publicProcedure, router } from "../lib/trpc";
|
||||||
|
import { ALLOWED_MODELS, DEFAULT_MODEL } from "../lib/ai-models";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
healthCheck: publicProcedure.query(() => {
|
healthCheck: publicProcedure.query(() => {
|
||||||
return "OK";
|
return "OK";
|
||||||
}),
|
}),
|
||||||
|
allowedModels: publicProcedure.query(() => {
|
||||||
|
return {
|
||||||
|
models: ALLOWED_MODELS,
|
||||||
|
defaultModel: DEFAULT_MODEL,
|
||||||
|
} as const;
|
||||||
|
}),
|
||||||
privateData: protectedProcedure.query(({ ctx }) => {
|
privateData: protectedProcedure.query(({ ctx }) => {
|
||||||
return {
|
return {
|
||||||
message: "This is private",
|
message: "This is private",
|
||||||
user: ctx.user,
|
user: ctx.user,
|
||||||
|
aiModel: ctx.aiModel ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ VITE_APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1
|
|||||||
VITE_APPWRITE_PROJECT_ID=<PROJECT_ID>
|
VITE_APPWRITE_PROJECT_ID=<PROJECT_ID>
|
||||||
VITE_APPWRITE_DB_ID=<DATABASE_ID>
|
VITE_APPWRITE_DB_ID=<DATABASE_ID>
|
||||||
VITE_APPWRITE_COLLECTION_ID=<COLLECTION_ID>
|
VITE_APPWRITE_COLLECTION_ID=<COLLECTION_ID>
|
||||||
VITE_APPWRITE_BUCKET_ID=<BUCKET_ID>
|
VITE_APPWRITE_BUCKET_ID=<BUCKET_ID>
|
||||||
|
# Server URL used by the tRPC client
|
||||||
|
VITE_SERVER_URL=http://localhost:8787
|
||||||
@@ -35,6 +35,13 @@ export default function UserMenu() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="bg-card" sideOffset={16}>
|
<DropdownMenuContent align="end" className="bg-card" sideOffset={16}>
|
||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
navigate({ to: "/settings" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async function postBinary(url: string, file: File): Promise<{ text: string }> {
|
|||||||
async function postJson<TReq extends object, TRes>(url: string, body: TReq): Promise<TRes> {
|
async function postJson<TReq extends object, TRes>(url: string, body: TReq): Promise<TRes> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", "x-ai-model": localStorage.getItem("aiModel") ?? "" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
||||||
import { Route as SpaceRouteImport } from './routes/space'
|
import { Route as SpaceRouteImport } from './routes/space'
|
||||||
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as PasswordRouteImport } from './routes/password'
|
import { Route as PasswordRouteImport } from './routes/password'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
@@ -26,6 +27,11 @@ const SpaceRoute = SpaceRouteImport.update({
|
|||||||
path: '/space',
|
path: '/space',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SettingsRoute = SettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const PasswordRoute = PasswordRouteImport.update({
|
const PasswordRoute = PasswordRouteImport.update({
|
||||||
id: '/password',
|
id: '/password',
|
||||||
path: '/password',
|
path: '/password',
|
||||||
@@ -52,6 +58,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/password': typeof PasswordRoute
|
'/password': typeof PasswordRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
'/space': typeof SpaceRoute
|
'/space': typeof SpaceRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
}
|
}
|
||||||
@@ -60,6 +67,7 @@ export interface FileRoutesByTo {
|
|||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/password': typeof PasswordRoute
|
'/password': typeof PasswordRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
'/space': typeof SpaceRoute
|
'/space': typeof SpaceRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
}
|
}
|
||||||
@@ -69,6 +77,7 @@ export interface FileRoutesById {
|
|||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/password': typeof PasswordRoute
|
'/password': typeof PasswordRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
'/space': typeof SpaceRoute
|
'/space': typeof SpaceRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
}
|
}
|
||||||
@@ -79,16 +88,25 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/password'
|
| '/password'
|
||||||
|
| '/settings'
|
||||||
| '/space'
|
| '/space'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/dashboard' | '/login' | '/password' | '/space' | '/verify-email'
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/login'
|
||||||
|
| '/password'
|
||||||
|
| '/settings'
|
||||||
|
| '/space'
|
||||||
|
| '/verify-email'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/password'
|
| '/password'
|
||||||
|
| '/settings'
|
||||||
| '/space'
|
| '/space'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -98,6 +116,7 @@ export interface RootRouteChildren {
|
|||||||
DashboardRoute: typeof DashboardRoute
|
DashboardRoute: typeof DashboardRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
PasswordRoute: typeof PasswordRoute
|
PasswordRoute: typeof PasswordRoute
|
||||||
|
SettingsRoute: typeof SettingsRoute
|
||||||
SpaceRoute: typeof SpaceRoute
|
SpaceRoute: typeof SpaceRoute
|
||||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
}
|
}
|
||||||
@@ -118,6 +137,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SpaceRouteImport
|
preLoaderRoute: typeof SpaceRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/settings': {
|
||||||
|
id: '/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/settings'
|
||||||
|
preLoaderRoute: typeof SettingsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/password': {
|
'/password': {
|
||||||
id: '/password'
|
id: '/password'
|
||||||
path: '/password'
|
path: '/password'
|
||||||
@@ -154,6 +180,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
DashboardRoute: DashboardRoute,
|
DashboardRoute: DashboardRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
PasswordRoute: PasswordRoute,
|
PasswordRoute: PasswordRoute,
|
||||||
|
SettingsRoute: SettingsRoute,
|
||||||
SpaceRoute: SpaceRoute,
|
SpaceRoute: SpaceRoute,
|
||||||
VerifyEmailRoute: VerifyEmailRoute,
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
161
apps/web/src/routes/settings.tsx
Normal file
161
apps/web/src/routes/settings.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient, account } from "@/lib/auth-client";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { trpc } from "@/utils/trpc";
|
||||||
|
import { ALLOWED_MODELS } from "../../../server/src/lib/ai-models";
|
||||||
|
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings")({
|
||||||
|
component: SettingsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SettingsPage() {
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [model, setModel] = useState<string>("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Load allowed models from server
|
||||||
|
const allowed = useQuery(trpc.allowedModels.queryOptions());
|
||||||
|
|
||||||
|
// Derive account info safely
|
||||||
|
const accountName = session?.name ?? "";
|
||||||
|
const accountEmail = session?.email ?? "";
|
||||||
|
const emailVerified = Boolean(session?.emailVerification);
|
||||||
|
|
||||||
|
// Initial load of preference from Appwrite prefs or localStorage fallback
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && !session) {
|
||||||
|
navigate({ to: "/login" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromStorage = () => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("aiModel") || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadPrefs() {
|
||||||
|
try {
|
||||||
|
if (!session) return;
|
||||||
|
// Appwrite stores arbitrary preferences per user
|
||||||
|
const me = await account.get();
|
||||||
|
const prefs = (me as any).prefs as Record<string, unknown> | undefined;
|
||||||
|
const existing = (prefs?.["aiModel"] as string) || fromStorage();
|
||||||
|
const defaultModel = allowed.data?.defaultModel || ALLOWED_MODELS[0];
|
||||||
|
setModel(existing || defaultModel);
|
||||||
|
} catch {
|
||||||
|
const defaultModel = allowed.data?.defaultModel || ALLOWED_MODELS[0];
|
||||||
|
setModel(fromStorage() || defaultModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPrefs();
|
||||||
|
}, [session, isPending, navigate, allowed.data?.defaultModel]);
|
||||||
|
|
||||||
|
const models = useMemo(() => {
|
||||||
|
const list = allowed.data?.models ?? ALLOWED_MODELS;
|
||||||
|
// Shape to id+name (use id as label by default)
|
||||||
|
return list.map((id) => ({ id, name: id }));
|
||||||
|
}, [allowed.data?.models]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!session) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// Persist to Appwrite preferences
|
||||||
|
await account.updatePrefs({ aiModel: model } as any);
|
||||||
|
// Persist locally for quick header injection
|
||||||
|
try {
|
||||||
|
localStorage.setItem("aiModel", model);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// Invalidate session cache to refresh prefs quickly if needed
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["session", "me"] });
|
||||||
|
toast.success("Preferences saved");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "Failed to save preferences");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <div className="container mx-auto max-w-5xl px-4 py-10">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-5xl px-4 py-10">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card className="border-border/60 bg-card/60 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account</CardTitle>
|
||||||
|
<CardDescription>Your account information</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Name</span>
|
||||||
|
<span className="font-medium">{accountName || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Email</span>
|
||||||
|
<span className="font-medium">{accountEmail || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Email Verified</span>
|
||||||
|
<span className={"font-medium " + (emailVerified ? "text-emerald-400" : "text-amber-400")}>{emailVerified ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/60 bg-card/60 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI Model</CardTitle>
|
||||||
|
<CardDescription>Select the model for AI operations</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="model">Model</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
id="model"
|
||||||
|
className="w-full appearance-none rounded-md border border-border bg-background px-3 py-2 pr-10 text-sm outline-none ring-0 transition focus:border-primary"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
>
|
||||||
|
{models.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 grid w-10 place-items-center text-muted-foreground">▾</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => window.history.back()}>Cancel</Button>
|
||||||
|
<Button disabled={isSaving || !model} onClick={handleSave}>
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,16 @@ export const trpcClient = createTRPCClient<AppRouter>({
|
|||||||
if (jwt) {
|
if (jwt) {
|
||||||
headers.set("Authorization", `Bearer ${jwt}`);
|
headers.set("Authorization", `Bearer ${jwt}`);
|
||||||
}
|
}
|
||||||
|
// Inject selected AI model into every request for server-side routing
|
||||||
|
// Preference is saved by the Settings page to localStorage (and Appwrite)
|
||||||
|
try {
|
||||||
|
const model = localStorage.getItem("aiModel") ?? "";
|
||||||
|
if (model) {
|
||||||
|
headers.set("x-ai-model", model);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore storage access issues (e.g., SSR or privacy modes)
|
||||||
|
}
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
Reference in New Issue
Block a user