From f4e996d75304195b57b0997f8e74893babeb6c1e Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Wed, 10 Sep 2025 20:10:48 -0300 Subject: [PATCH] feat: allow user-configurable AI model selection with server-side validation --- apps/server/src/index.ts | 9 +- apps/server/src/lib/ai-models.ts | 19 +++ apps/server/src/lib/context.ts | 11 +- apps/server/src/routers/index.ts | 8 ++ apps/web/.env.example | 4 +- apps/web/src/components/user-menu.tsx | 7 ++ apps/web/src/lib/tldraw/processing.ts | 2 +- apps/web/src/routeTree.gen.ts | 29 ++++- apps/web/src/routes/settings.tsx | 161 ++++++++++++++++++++++++++ apps/web/src/utils/trpc.ts | 10 ++ 10 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/lib/ai-models.ts create mode 100644 apps/web/src/routes/settings.tsx diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f24529c..fa876c7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -7,6 +7,7 @@ import { env } from "hono/adapter"; import { cors } from "hono/cors"; import { logger as honoLogger } from "hono/logger"; import { createContext } from "./lib/context"; +import { sanitizeModel } from "./lib/ai-models"; import { logger } from "./lib/logger"; import { appRouter } from "./routers/index"; @@ -30,7 +31,7 @@ app.use( cors({ origin: process.env.CORS_ORIGIN || "", allowMethods: ["GET", "POST", "OPTIONS"], - allowHeaders: ["Content-Type", "Authorization"], + allowHeaders: ["Content-Type", "Authorization", "x-ai-model"], credentials: true, }) ); @@ -91,8 +92,9 @@ app.post("/ai/ocr", async (c) => { const base64 = arrayBufferToBase64(await blob.arrayBuffer()); const dataUrl = `data:${contentType};base64,${base64}`; + const model = sanitizeModel(c.req.header("x-ai-model")); const body = { - model: "openrouter/sonoma-sky-alpha", + model, messages: [ { role: "system", @@ -161,8 +163,9 @@ app.post("/ai/generate", async (c) => { return c.json({ error: "Missing prompt" }, 400); } + const modelGen = sanitizeModel(c.req.header("x-ai-model")); const body = { - model: "openrouter/sonoma-sky-alpha", + model: modelGen, temperature, messages: [ { role: "system", content: system }, diff --git a/apps/server/src/lib/ai-models.ts b/apps/server/src/lib/ai-models.ts new file mode 100644 index 0000000..866001a --- /dev/null +++ b/apps/server/src/lib/ai-models.ts @@ -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; +} diff --git a/apps/server/src/lib/context.ts b/apps/server/src/lib/context.ts index aafd3d1..79bbc41 100644 --- a/apps/server/src/lib/context.ts +++ b/apps/server/src/lib/context.ts @@ -1,5 +1,6 @@ import type { Context as HonoContext } from "hono"; import { Account, Client } from "node-appwrite"; +import { sanitizeModel } from "./ai-models"; // Hoisted regex for performance and linting const BEARER_REGEX = /^Bearer\s+(.+)$/i; @@ -16,12 +17,16 @@ export type 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 projectId = process.env.APPWRITE_PROJECT_ID; if (!(endpoint && projectId)) { // Appwrite not configured; treat as unauthenticated - return { user: null as AuthUser }; + return { user: null as AuthUser, aiModel }; } // Initialize client per request @@ -68,9 +73,9 @@ export async function createContext({ context }: CreateContextOptions) { name: user.name ?? null, email: user.email ?? null, }; - return { user: minimal }; + return { user: minimal, aiModel }; } catch { - return { user: null as AuthUser }; + return { user: null as AuthUser, aiModel }; } } diff --git a/apps/server/src/routers/index.ts b/apps/server/src/routers/index.ts index a14289e..3bef46e 100644 --- a/apps/server/src/routers/index.ts +++ b/apps/server/src/routers/index.ts @@ -1,13 +1,21 @@ import { protectedProcedure, publicProcedure, router } from "../lib/trpc"; +import { ALLOWED_MODELS, DEFAULT_MODEL } from "../lib/ai-models"; export const appRouter = router({ healthCheck: publicProcedure.query(() => { return "OK"; }), + allowedModels: publicProcedure.query(() => { + return { + models: ALLOWED_MODELS, + defaultModel: DEFAULT_MODEL, + } as const; + }), privateData: protectedProcedure.query(({ ctx }) => { return { message: "This is private", user: ctx.user, + aiModel: ctx.aiModel ?? null, }; }), }); diff --git a/apps/web/.env.example b/apps/web/.env.example index 0cd1929..ad6f7a2 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -2,4 +2,6 @@ VITE_APPWRITE_ENDPOINT=https://.cloud.appwrite.io/v1 VITE_APPWRITE_PROJECT_ID= VITE_APPWRITE_DB_ID= VITE_APPWRITE_COLLECTION_ID= -VITE_APPWRITE_BUCKET_ID= \ No newline at end of file +VITE_APPWRITE_BUCKET_ID= +# Server URL used by the tRPC client +VITE_SERVER_URL=http://localhost:8787 \ No newline at end of file diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index 29c2d70..a58946d 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -35,6 +35,13 @@ export default function UserMenu() { My Account + { + navigate({ to: "/settings" }); + }} + > + Settings + { await authClient.signOut(); diff --git a/apps/web/src/lib/tldraw/processing.ts b/apps/web/src/lib/tldraw/processing.ts index 641d619..c221bcc 100644 --- a/apps/web/src/lib/tldraw/processing.ts +++ b/apps/web/src/lib/tldraw/processing.ts @@ -19,7 +19,7 @@ async function postBinary(url: string, file: File): Promise<{ text: string }> { async function postJson(url: string, body: TReq): Promise { const res = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "x-ai-model": localStorage.getItem("aiModel") ?? "" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`Request failed: ${res.status}`); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0a4e827..7c8579c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as VerifyEmailRouteImport } from './routes/verify-email' import { Route as SpaceRouteImport } from './routes/space' +import { Route as SettingsRouteImport } from './routes/settings' import { Route as PasswordRouteImport } from './routes/password' import { Route as LoginRouteImport } from './routes/login' import { Route as DashboardRouteImport } from './routes/dashboard' @@ -26,6 +27,11 @@ const SpaceRoute = SpaceRouteImport.update({ path: '/space', getParentRoute: () => rootRouteImport, } as any) +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => rootRouteImport, +} as any) const PasswordRoute = PasswordRouteImport.update({ id: '/password', path: '/password', @@ -52,6 +58,7 @@ export interface FileRoutesByFullPath { '/dashboard': typeof DashboardRoute '/login': typeof LoginRoute '/password': typeof PasswordRoute + '/settings': typeof SettingsRoute '/space': typeof SpaceRoute '/verify-email': typeof VerifyEmailRoute } @@ -60,6 +67,7 @@ export interface FileRoutesByTo { '/dashboard': typeof DashboardRoute '/login': typeof LoginRoute '/password': typeof PasswordRoute + '/settings': typeof SettingsRoute '/space': typeof SpaceRoute '/verify-email': typeof VerifyEmailRoute } @@ -69,6 +77,7 @@ export interface FileRoutesById { '/dashboard': typeof DashboardRoute '/login': typeof LoginRoute '/password': typeof PasswordRoute + '/settings': typeof SettingsRoute '/space': typeof SpaceRoute '/verify-email': typeof VerifyEmailRoute } @@ -79,16 +88,25 @@ export interface FileRouteTypes { | '/dashboard' | '/login' | '/password' + | '/settings' | '/space' | '/verify-email' fileRoutesByTo: FileRoutesByTo - to: '/' | '/dashboard' | '/login' | '/password' | '/space' | '/verify-email' + to: + | '/' + | '/dashboard' + | '/login' + | '/password' + | '/settings' + | '/space' + | '/verify-email' id: | '__root__' | '/' | '/dashboard' | '/login' | '/password' + | '/settings' | '/space' | '/verify-email' fileRoutesById: FileRoutesById @@ -98,6 +116,7 @@ export interface RootRouteChildren { DashboardRoute: typeof DashboardRoute LoginRoute: typeof LoginRoute PasswordRoute: typeof PasswordRoute + SettingsRoute: typeof SettingsRoute SpaceRoute: typeof SpaceRoute VerifyEmailRoute: typeof VerifyEmailRoute } @@ -118,6 +137,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SpaceRouteImport parentRoute: typeof rootRouteImport } + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport + parentRoute: typeof rootRouteImport + } '/password': { id: '/password' path: '/password' @@ -154,6 +180,7 @@ const rootRouteChildren: RootRouteChildren = { DashboardRoute: DashboardRoute, LoginRoute: LoginRoute, PasswordRoute: PasswordRoute, + SettingsRoute: SettingsRoute, SpaceRoute: SpaceRoute, VerifyEmailRoute: VerifyEmailRoute, } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx new file mode 100644 index 0000000..3f5804a --- /dev/null +++ b/apps/web/src/routes/settings.tsx @@ -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(""); + 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 | 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
Loading...
; + } + + return ( +
+
+ + + Account + Your account information + + +
+ Name + {accountName || "—"} +
+
+ Email + {accountEmail || "—"} +
+
+ Email Verified + {emailVerified ? "Yes" : "No"} +
+
+
+ + + + AI Model + Select the model for AI operations + + +
+
+ +
+ + +
+
+ +
+ + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/utils/trpc.ts b/apps/web/src/utils/trpc.ts index 771568a..ca3d81a 100644 --- a/apps/web/src/utils/trpc.ts +++ b/apps/web/src/utils/trpc.ts @@ -30,6 +30,16 @@ export const trpcClient = createTRPCClient({ if (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, { ...options, headers,