From 50079874455d85d85fbd725382d1c05dd3797da5 Mon Sep 17 00:00:00 2001 From: Antoni Czaplicki Date: Tue, 9 Jun 2026 20:36:23 +0200 Subject: [PATCH] feat: add AI model selection and provider support --- .env.example | 1 + package.json | 1 + pnpm-lock.yaml | 15 +++ src/app/ai/chat/route.ts | 22 +++- src/components/ai/ai-chat.tsx | 57 +++++++- src/components/ai/ai-model-provider-icon.tsx | 42 ++++++ .../assistant-ui/model-selector.tsx | 124 ++++++++++++++++++ src/components/assistant-ui/thread.tsx | 20 ++- src/components/profile/ai-settings-form.tsx | 72 +++++++++- src/env.ts | 1 + src/lib/ai/model.ts | 79 ++++++++++- src/lib/ai/models.ts | 98 ++++++++++++++ src/types/user.ts | 6 +- 13 files changed, 520 insertions(+), 18 deletions(-) create mode 100644 src/components/ai/ai-model-provider-icon.tsx create mode 100644 src/components/assistant-ui/model-selector.tsx create mode 100644 src/lib/ai/models.ts diff --git a/.env.example b/.env.example index f8bfb679..5f6908f8 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,4 @@ NEXT_PUBLIC_TURN_USERNAME= NEXT_PUBLIC_TURN_CREDENTIAL= S3_URL=https://s3.b.solvro.pl OPENAI_API_KEY=your-openai-api-key +ANTHROPIC_API_KEY=your-anthropic-api-key diff --git a/package.json b/package.json index 6f54c367..1544297b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@next/eslint-plugin-next": "^16.1.1" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.82", "@ai-sdk/openai": "^3.0.63", "@ai-sdk/react": "^3.0.184", "@assistant-ui/react": "^0.14.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cb0ab66..74c4acaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.82 + version: 3.0.82(zod@4.4.2) '@ai-sdk/openai': specifier: ^3.0.63 version: 3.0.63(zod@4.4.2) @@ -300,6 +303,12 @@ packages: peerDependencies: eslint: ^9.9.1 || ^10.0.0 + '@ai-sdk/anthropic@3.0.82': + resolution: {integrity: sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.114': resolution: {integrity: sha512-MqkZ5sd+qiq6RgIxELkoFQXg2/JwK+WCMaot7U+rtrZpWJl3fSyYvc28SC03b256o4F7OXjQtdjTqs81B2w+dA==} engines: {node: '>=18'} @@ -6662,6 +6671,12 @@ snapshots: - supports-color - typescript + '@ai-sdk/anthropic@3.0.82(zod@4.4.2)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@4.4.2) + zod: 4.4.2 + '@ai-sdk/gateway@3.0.114(zod@4.4.2)': dependencies: '@ai-sdk/provider': 3.0.10 diff --git a/src/app/ai/chat/route.ts b/src/app/ai/chat/route.ts index 10f863e1..50732f7b 100644 --- a/src/app/ai/chat/route.ts +++ b/src/app/ai/chat/route.ts @@ -1,12 +1,13 @@ import { frontendTools } from "@assistant-ui/react-ai-sdk"; import { convertToModelMessages, stepCountIs, streamText } from "ai"; -import type { JSONSchema7, ModelMessage, UIMessage } from "ai"; +import type { JSONSchema7, LanguageModel, ModelMessage, UIMessage } from "ai"; import { cookies } from "next/headers"; import { z } from "zod"; import { env } from "@/env"; import { resolveImages } from "@/lib/ai/images"; -import { chatModel } from "@/lib/ai/model"; +import { getChatModelForUser } from "@/lib/ai/model"; +import { isAiModel } from "@/lib/ai/models"; import type { LabeledImage } from "@/lib/ai/prompts"; import { checkRateLimit, @@ -65,7 +66,7 @@ const getQuestionSchema = z.object({ }); export async function POST(request: Request) { - if (!env.NEXT_PUBLIC_AI_ENABLED || env.OPENAI_API_KEY === undefined) { + if (!env.NEXT_PUBLIC_AI_ENABLED) { return new Response("AI is not configured", { status: 503 }); } @@ -93,6 +94,7 @@ export async function POST(request: Request) { images, canEdit, quizId: rawQuizId, + config, tools: clientTools, } = (await request.json()) as { messages: UIMessage[]; @@ -100,13 +102,25 @@ export async function POST(request: Request) { images?: LabeledImage[]; canEdit?: boolean; quizId?: string; + config?: { modelName?: unknown }; tools?: Record; }; const quizId = z.uuid().safeParse(rawQuizId).success ? rawQuizId : undefined; + const aiModel = isAiModel(config?.modelName) ? config.modelName : undefined; const cookieStore = await cookies(); const accessToken = cookieStore.get(AUTH_COOKIES.ACCESS_TOKEN)?.value; + let model: LanguageModel; + try { + model = getChatModelForUser({ + accountLevel: user.account_level, + requestedModel: aiModel, + }); + } catch (error) { + console.error("Failed to resolve AI chat model", error); + return new Response("AI model is not configured", { status: 503 }); + } const modelMessages = await convertToModelMessages(messages); const imageParts = @@ -130,7 +144,7 @@ export async function POST(request: Request) { : []; const result = streamText({ - model: chatModel, + model, ...(system === undefined ? {} : { system }), messages: [...imageContext, ...modelMessages], stopWhen: stepCountIs(5), diff --git a/src/components/ai/ai-chat.tsx b/src/components/ai/ai-chat.tsx index 22f63311..698e2c59 100644 --- a/src/components/ai/ai-chat.tsx +++ b/src/components/ai/ai-chat.tsx @@ -16,17 +16,28 @@ import { MinimizeIcon, XIcon, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { AppContext } from "@/app-context"; import { AiChatProvider } from "@/components/ai/ai-chat-context"; +import { AiModelProviderIcon } from "@/components/ai/ai-model-provider-icon"; import { DisableAiToolUI } from "@/components/ai/tool-ui-disable-ai"; import { EditQuestionToolUI } from "@/components/ai/tool-ui-edit-question"; import { GeneratedQuestionsToolUI } from "@/components/ai/tool-ui-question"; +import { ModelSelector } from "@/components/assistant-ui/model-selector"; import { Thread } from "@/components/assistant-ui/thread"; import { Button } from "@/components/ui/button"; +import { useUserSettings } from "@/hooks/use-user-settings"; +import { + SELECTABLE_AI_MODEL_OPTIONS, + isSelectableAiModel, + resolveSelectableAiModel, +} from "@/lib/ai/models"; +import type { SelectableAiModel } from "@/lib/ai/models"; import { buildChatSystemPrompt, collectQuestionImages } from "@/lib/ai/prompts"; import { cn } from "@/lib/utils"; import type { Question } from "@/types/quiz"; +import { ACCOUNT_LEVEL, DEFAULT_USER_SETTINGS } from "@/types/user"; type ChatMode = "popup" | "sheet"; @@ -73,7 +84,7 @@ function ChatRuntime({ useEffect(() => { systemRef.current = system; imagesRef.current = images; - }); + }, [images, system]); const transport = useMemo( () => @@ -144,8 +155,27 @@ export function AiChat({ userName, canEdit = false, }: AiChatProps) { + const { user } = useContext(AppContext); const [mode, setMode] = useState("popup"); const [chatKey, setChatKey] = useState(0); + const { data: settings = DEFAULT_USER_SETTINGS } = useUserSettings({ + placeholderData: DEFAULT_USER_SETTINGS, + }); + const canSelectAiModel = user?.account_level === ACCOUNT_LEVEL.GOLD; + const defaultAiModel = resolveSelectableAiModel(settings.default_ai_model); + const [manualAiModel, setManualAiModel] = useState( + null, + ); + const selectedAiModel = manualAiModel ?? defaultAiModel; + const modelSelectorOptions = useMemo( + () => + SELECTABLE_AI_MODEL_OPTIONS.map((model) => ({ + id: model.value, + name: model.label, + icon: , + })), + [], + ); useEffect(() => { if (!open) { @@ -196,9 +226,9 @@ export function AiChat({ >
- Asystent AI + Asystent AI
-
+
diff --git a/src/components/ai/ai-model-provider-icon.tsx b/src/components/ai/ai-model-provider-icon.tsx new file mode 100644 index 00000000..ae38c81d --- /dev/null +++ b/src/components/ai/ai-model-provider-icon.tsx @@ -0,0 +1,42 @@ +import type { ComponentType } from "react"; +import { SiAnthropic, SiOpenai } from "react-icons/si"; + +import type { AiModelProvider } from "@/lib/ai/models"; +import { cn } from "@/lib/utils"; + +interface AiModelProviderIconProps { + className?: string; + provider: AiModelProvider; +} + +const AI_MODEL_PROVIDER_ICON_CONFIG = { + Anthropic: { + Icon: SiAnthropic, + className: "text-[#D97757]", + }, + OpenAI: { + Icon: SiOpenai, + className: "text-foreground", + }, +} as const satisfies Record< + AiModelProvider, + { + Icon: ComponentType<{ className?: string; "aria-hidden"?: "true" }>; + className: string; + } +>; + +export function AiModelProviderIcon({ + className, + provider, +}: AiModelProviderIconProps) { + const { Icon, className: providerClassName } = + AI_MODEL_PROVIDER_ICON_CONFIG[provider]; + + return ( +