diff --git a/src/app/admin/chat/_components/ModelSelector.tsx b/src/app/admin/chat/_components/ModelSelector.tsx new file mode 100644 index 0000000..5b3273c --- /dev/null +++ b/src/app/admin/chat/_components/ModelSelector.tsx @@ -0,0 +1,48 @@ +'use client' +import { trpc } from '~/app/_trpc/Client' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/components/ui/select' + +export default function ModelSelector({ initialValue }: { initialValue: string }) { + const utils = trpc.useUtils() + const { data: models, isLoading, error } = trpc.chat.listModels.useQuery() + const { data: model = initialValue } = trpc.chat.getModel.useQuery(undefined, { + initialData: initialValue, + }) + + const mutation = trpc.chat.updateModel.useMutation({ + onSuccess: () => utils.chat.getModel.invalidate(), + }) + + // Ensure the currently-saved model is always selectable, even if the + // OpenAI list doesn't include it (e.g. a deprecated model). + const options = Array.from(new Set([model, ...(models ?? [])])).filter(Boolean) + + return ( +
+ +
+ {mutation.isPending && Saving…} + {mutation.isSuccess && !mutation.isPending && Saved} + {error && Failed to load models: {error.message}} + {mutation.error && {mutation.error.message}} +
+
+ ) +} diff --git a/src/app/admin/chat/page.tsx b/src/app/admin/chat/page.tsx index 4dfa709..8e24c6e 100644 --- a/src/app/admin/chat/page.tsx +++ b/src/app/admin/chat/page.tsx @@ -1,18 +1,31 @@ import { servTrpc } from '~/app/_trpc/ServerClient' import SystemPromptForm from './_components/SystemPromptForm' +import ModelSelector from './_components/ModelSelector' export default async function SystemPromptPage() { const prompt = await servTrpc.chat.getSystemPrompt() + const model = await servTrpc.chat.getModel() return ( -
-
-

AI System Prompt

-

- This prompt is sent to the model on every chat request. -

+
+
+
+

AI Model

+

+ The OpenAI model used to respond to chat requests. +

+
+ +
+
+
+

AI System Prompt

+

+ This prompt is sent to the model on every chat request. +

+
+
-
) } diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 6c9d5e9..896dedc 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -32,6 +32,7 @@ export async function POST(req: Request) { if (!session) return new Response('Session not found', { status: 404 }) const systemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.' + const model = await servTrpc.chat.getModel() // Save the latest user message const lastMessage = messages[messages.length - 1] @@ -46,7 +47,7 @@ export async function POST(req: Request) { } const result = streamText({ - model: openai('gpt-5-mini'), + model: openai(model), system: systemPrompt, messages: await convertToModelMessages(messages), tools: { diff --git a/src/server/dbschema/schema.ts b/src/server/dbschema/schema.ts index b586030..42250b3 100644 --- a/src/server/dbschema/schema.ts +++ b/src/server/dbschema/schema.ts @@ -175,6 +175,7 @@ export const chatMessageRelations = relations(chatMessage, ({ one }) => ({ export const systemSettings = createTable( "systemSetting", (d) => ({ - systemPropmt: d.text() + systemPropmt: d.text(), + model: d.text() }) ) diff --git a/src/server/routers/chat.ts b/src/server/routers/chat.ts index 055aa8f..f3916ec 100644 --- a/src/server/routers/chat.ts +++ b/src/server/routers/chat.ts @@ -7,6 +7,26 @@ import { isAdmin } from '~/app/actions'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { clerkClient, auth } from '@clerk/nextjs/server' +import { env } from '~/env' + +export const DEFAULT_MODEL = 'gpt-5-mini' + +// Models returned by the OpenAI API that aren't usable for chat completions. +const NON_CHAT_MODEL = /embedding|image|audio|realtime|transcribe|tts|whisper|moderation|dall-e|search|codex|instruct/ + +async function readSettings() { + return db.select().from(systemSettings).limit(1).then((r) => r[0]) +} + +async function writeSettings(values: { systemPropmt?: string | null; model?: string | null }) { + const current = await readSettings() + await db.delete(systemSettings) + await db.insert(systemSettings).values({ + systemPropmt: values.systemPropmt ?? current?.systemPropmt ?? null, + model: values.model ?? current?.model ?? null, + }) +} + export const chatRouter = router({ getSession: publicProcedure.query(async () => { const { userId } = await auth(); @@ -66,13 +86,34 @@ export const chatRouter = router({ }), getSystemPrompt: publicProcedure.query(async () => { - const row = await db.select().from(systemSettings).limit(1).then((r) => r[0]) + const row = await readSettings() return row?.systemPropmt ?? '' }), updateSystemPrompt: publicProcedure.input(z.object({ prompt: z.string() })).mutation(async ({ input }) => { if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' }) - await db.delete(systemSettings) - await db.insert(systemSettings).values({ systemPropmt: input.prompt }) + await writeSettings({ systemPropmt: input.prompt }) + }), + getModel: publicProcedure.query(async () => { + const row = await readSettings() + return row?.model ?? DEFAULT_MODEL + }), + listModels: publicProcedure.query(async () => { + if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' }) + const res = await fetch('https://api.openai.com/v1/models', { + headers: { Authorization: `Bearer ${env.OPENAI_API_KEY}` }, + }) + if (!res.ok) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `failed to fetch models (${res.status})` }) + } + const json = (await res.json()) as { data: { id: string }[] } + return json.data + .map((m) => m.id) + .filter((id) => (id.startsWith('gpt') || /^o\d/.test(id) || id.startsWith('chatgpt')) && !NON_CHAT_MODEL.test(id)) + .sort() + }), + updateModel: publicProcedure.input(z.object({ model: z.string() })).mutation(async ({ input }) => { + if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' }) + await writeSettings({ model: input.model }) }), })