Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 42 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 19 additions & 4 deletions src/app/ai/explain/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 });
}

Expand All @@ -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) {
Expand All @@ -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
Expand Down
23 changes: 19 additions & 4 deletions src/app/ai/hint/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 });
}

Expand All @@ -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) {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/app/quiz/[quizId]/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
{showAi && showAiExplain && currentQuestion != null ? (
questionChecked ? (
<AiExplanationCard
defaultAiModel={quiz.user_settings?.default_ai_model}
question={currentQuestion}
onClose={() => {
setShowAiExplain(false);
Expand All @@ -402,6 +403,7 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
/>
) : (
<AiHintCard
defaultAiModel={quiz.user_settings?.default_ai_model}
question={currentQuestion}
onClose={() => {
setShowAiExplain(false);
Expand Down
20 changes: 18 additions & 2 deletions src/components/ai/ai-explain-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -303,6 +315,7 @@ export function AiHintCard({
const { completion, isLoading, error, retryAfter, handleStart, stop } =
useQuestionCompletion({
api: "/ai/hint",
defaultAiModel,
question,
onClose,
});
Expand Down Expand Up @@ -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,
});
Expand Down
18 changes: 12 additions & 6 deletions src/components/ai/ai-model-provider-icon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Anthropic, OpenAI } from "@lobehub/icons";
import { Anthropic, Grok, OpenAI } from "@lobehub/icons";

import type { AiModelProvider } from "@/lib/ai/models";

Expand All @@ -11,9 +11,15 @@ export function AiModelProviderIcon({
className,
provider,
}: AiModelProviderIconProps) {
return provider === "Anthropic" ? (
<Anthropic aria-hidden="true" className={className} />
) : (
<OpenAI aria-hidden="true" className={className} />
);
switch (provider) {
case "Anthropic": {
return <Anthropic aria-hidden="true" className={className} />;
}
case "xAI": {
return <Grok aria-hidden="true" className={className} />;
}
case "OpenAI": {
return <OpenAI aria-hidden="true" className={className} />;
}
}
}
1 change: 1 addition & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const env = createEnv({
INTERNAL_API_KEY: z.string().min(1).optional(),
OPENAI_API_KEY: z.string().min(1).optional(),
ANTHROPIC_API_KEY: z.string().min(1).optional(),
XAI_API_KEY: z.string().min(1).optional(),
AI_CHAT_RATE_LIMIT: z.coerce.number().default(10),
AI_CHAT_RATE_WINDOW: z.coerce.number().default(60),
AI_EXPLAIN_RATE_LIMIT: z.coerce.number().default(15),
Expand Down
Loading
Loading