diff --git a/.env.example b/.env.example index 5f6908f8..c29b0f44 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,4 @@ 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 +XAI_API_KEY=your-xai-api-key diff --git a/package.json b/package.json index 46dd1906..8264522f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@ai-sdk/anthropic": "^3.0.82", "@ai-sdk/openai": "^3.0.63", "@ai-sdk/react": "^3.0.184", + "@ai-sdk/xai": "^3.0.96", "@assistant-ui/react": "^0.14.5", "@assistant-ui/react-ai-sdk": "^1.3.26", "@assistant-ui/react-markdown": "^0.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8055d4b..07df4dcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@ai-sdk/react': specifier: ^3.0.184 version: 3.0.184(react@19.2.5)(zod@4.4.2) + '@ai-sdk/xai': + specifier: ^3.0.96 + version: 3.0.96(zod@4.4.2) '@assistant-ui/react': specifier: ^0.14.5 version: 0.14.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@11.1.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) @@ -318,6 +321,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai-compatible@2.0.51': + resolution: {integrity: sha512-A6qfyaVs4lxmRxRux6U3ViOa8mMbsSd0OaHghpei2MpiBT6791J4zFH5MN7kaW1tLfQ246rSC2DVTMOavsycjQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@3.0.63': resolution: {integrity: sha512-4yY/m8a57MNNVoJCsXuNblKf6BO4yuAuLKRX4tzSNffBEBSp1FlcWdPE0Z4FkqUeS0AJhYSSqp0GIiA/cIcDNA==} engines: {node: '>=18'} @@ -330,6 +339,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.30': + resolution: {integrity: sha512-VO7I+vPffqI5sMnPoUq5DCSqKIgQIk/naJWRdQVpz2ma2zoprC/lqiJiUEl2s6DfvTD76TbhD3q39ROjlA6rGw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@3.0.10': resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} engines: {node: '>=18'} @@ -340,6 +355,12 @@ packages: peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@ai-sdk/xai@3.0.96': + resolution: {integrity: sha512-a24jD29cQ3YocP7B4NUvNTb9s5CYR1ao2yU/QncT7QDwwOp4x/lq+6W+BdXGf1VucG7A0dx9jtyIOm6PE4JPyg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -8226,6 +8247,12 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.4.2 + '@ai-sdk/openai-compatible@2.0.51(zod@4.4.2)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.30(zod@4.4.2) + zod: 4.4.2 + '@ai-sdk/openai@3.0.63(zod@4.4.2)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -8239,6 +8266,13 @@ snapshots: eventsource-parser: 3.0.8 zod: 4.4.2 + '@ai-sdk/provider-utils@4.0.30(zod@4.4.2)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + zod: 4.4.2 + '@ai-sdk/provider@3.0.10': dependencies: json-schema: 0.4.0 @@ -8253,6 +8287,13 @@ snapshots: transitivePeerDependencies: - zod + '@ai-sdk/xai@3.0.96(zod@4.4.2)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.51(zod@4.4.2) + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.30(zod@4.4.2) + zod: 4.4.2 + '@alloc/quick-lru@5.2.0': {} '@ant-design/colors@8.0.1': @@ -9363,7 +9404,7 @@ snapshots: '@lobehub/emojilib': 1.0.0 antd-style: 4.1.0(@types/react@19.2.14)(antd@6.4.4(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) emoji-regex: 10.6.0 - es-toolkit: 1.46.1 + es-toolkit: 1.47.1 lucide-react: 0.562.0(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) diff --git a/src/app/ai/explain/route.ts b/src/app/ai/explain/route.ts index 78312cf3..1b453fba 100644 --- a/src/app/ai/explain/route.ts +++ b/src/app/ai/explain/route.ts @@ -1,8 +1,10 @@ import { streamText } from "ai"; +import type { LanguageModel } from "ai"; 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 { buildQuestionExplanationSystemPrompt, buildQuestionExplanationUserPrompt, @@ -20,7 +22,7 @@ import type { Question } from "@/types/quiz"; export const maxDuration = 30; 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 }); } @@ -42,8 +44,9 @@ export async function POST(request: Request) { return createRateLimitExceededResponse(rateLimitResult); } - const { question } = (await request.json()) as { + const { question, config } = (await request.json()) as { question?: Question; + config?: { modelName?: unknown }; }; if (question === undefined) { @@ -53,8 +56,20 @@ export async function POST(request: Request) { const imageParts = await resolveImages(collectQuestionImages(question)); const prompt = buildQuestionExplanationUserPrompt(question); + let model: LanguageModel; + try { + const aiModel = isAiModel(config?.modelName) ? config.modelName : undefined; + model = getChatModelForUser({ + accountLevel: user.account_level, + requestedModel: aiModel, + }); + } catch (error) { + console.error("Failed to resolve AI explanation model", error); + return new Response("AI model is not configured", { status: 503 }); + } + const result = streamText({ - model: chatModel, + model, system: buildQuestionExplanationSystemPrompt(), prompt: imageParts.length > 0 diff --git a/src/app/ai/hint/route.ts b/src/app/ai/hint/route.ts index fdb4b288..14706bed 100644 --- a/src/app/ai/hint/route.ts +++ b/src/app/ai/hint/route.ts @@ -1,8 +1,10 @@ import { streamText } from "ai"; +import type { LanguageModel } from "ai"; 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 { buildQuestionHintSystemPrompt, buildQuestionHintUserPrompt, @@ -20,7 +22,7 @@ import type { Question } from "@/types/quiz"; export const maxDuration = 30; 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 }); } @@ -42,8 +44,9 @@ export async function POST(request: Request) { return createRateLimitExceededResponse(rateLimitResult); } - const { question } = (await request.json()) as { + const { question, config } = (await request.json()) as { question?: Question; + config?: { modelName?: unknown }; }; if (question === undefined) { @@ -53,8 +56,20 @@ export async function POST(request: Request) { const imageParts = await resolveImages(collectQuestionImages(question)); const prompt = buildQuestionHintUserPrompt(question); + let model: LanguageModel; + try { + const aiModel = isAiModel(config?.modelName) ? config.modelName : undefined; + model = getChatModelForUser({ + accountLevel: user.account_level, + requestedModel: aiModel, + }); + } catch (error) { + console.error("Failed to resolve AI hint model", error); + return new Response("AI model is not configured", { status: 503 }); + } + const result = streamText({ - model: chatModel, + model, system: buildQuestionHintSystemPrompt(), prompt: imageParts.length > 0 diff --git a/src/app/quiz/[quizId]/client.tsx b/src/app/quiz/[quizId]/client.tsx index 5c5e47a8..a768b4b7 100644 --- a/src/app/quiz/[quizId]/client.tsx +++ b/src/app/quiz/[quizId]/client.tsx @@ -394,6 +394,7 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element { {showAi && showAiExplain && currentQuestion != null ? ( questionChecked ? ( { setShowAiExplain(false); @@ -402,6 +403,7 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element { /> ) : ( { setShowAiExplain(false); diff --git a/src/components/ai/ai-explain-card.tsx b/src/components/ai/ai-explain-card.tsx index cc77310c..36a59327 100644 --- a/src/components/ai/ai-explain-card.tsx +++ b/src/components/ai/ai-explain-card.tsx @@ -14,6 +14,7 @@ import { AiDisclaimer } from "@/components/ai/ai-disclaimer"; import { MarkdownRenderer } from "@/components/markdown-renderer"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { resolveSelectableAiModel } from "@/lib/ai/models"; import { cn } from "@/lib/utils"; import type { Question } from "@/types/quiz"; @@ -87,10 +88,12 @@ interface CompletionState { /* eslint-disable react-you-might-not-need-an-effect/no-event-handler */ function useQuestionCompletion({ api, + defaultAiModel, question, onClose, }: { api: string; + defaultAiModel?: string | null; question: Question; onClose: () => void; }): CompletionState { @@ -141,8 +144,15 @@ function useQuestionCompletion({ const startCompletion = useCallback(() => { setRetryAfter(null); startedRef.current = true; - void complete("generate", { body: { question } }); - }, [complete, question]); + void complete("generate", { + body: { + question, + config: { + modelName: resolveSelectableAiModel(defaultAiModel), + }, + }, + }); + }, [complete, defaultAiModel, question]); const handleStart = useCallback(() => { if (retryAfter !== null && retryAfter > 0) { @@ -288,12 +298,14 @@ function AiCardShell({ } interface AiHintCardProps { + defaultAiModel?: string | null; question: Question; onClose: () => void; onAnswerHints?: (hints: AnswerHint[]) => void; } export function AiHintCard({ + defaultAiModel, question, onClose, onAnswerHints, @@ -303,6 +315,7 @@ export function AiHintCard({ const { completion, isLoading, error, retryAfter, handleStart, stop } = useQuestionCompletion({ api: "/ai/hint", + defaultAiModel, question, onClose, }); @@ -378,17 +391,20 @@ export function AiHintCard({ } interface AiExplanationCardProps { + defaultAiModel?: string | null; question: Question; onClose: () => void; } export function AiExplanationCard({ + defaultAiModel, question, onClose, }: AiExplanationCardProps) { const { completion, isLoading, error, retryAfter, handleStart, stop } = useQuestionCompletion({ api: "/ai/explain", + defaultAiModel, question, onClose, }); diff --git a/src/components/ai/ai-model-provider-icon.tsx b/src/components/ai/ai-model-provider-icon.tsx index a4ae65a7..85995450 100644 --- a/src/components/ai/ai-model-provider-icon.tsx +++ b/src/components/ai/ai-model-provider-icon.tsx @@ -1,4 +1,4 @@ -import { Anthropic, OpenAI } from "@lobehub/icons"; +import { Anthropic, Grok, OpenAI } from "@lobehub/icons"; import type { AiModelProvider } from "@/lib/ai/models"; @@ -11,9 +11,15 @@ export function AiModelProviderIcon({ className, provider, }: AiModelProviderIconProps) { - return provider === "Anthropic" ? ( -