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 @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

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

22 changes: 18 additions & 4 deletions src/app/ai/chat/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 });
}

Expand Down Expand Up @@ -93,20 +94,33 @@ export async function POST(request: Request) {
images,
canEdit,
quizId: rawQuizId,
config,
tools: clientTools,
} = (await request.json()) as {
messages: UIMessage[];
system?: string;
images?: LabeledImage[];
canEdit?: boolean;
quizId?: string;
config?: { modelName?: unknown };
tools?: Record<string, { description?: string; parameters: JSONSchema7 }>;
};

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 =
Expand All @@ -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),
Expand Down
57 changes: 52 additions & 5 deletions src/components/ai/ai-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -73,7 +84,7 @@ function ChatRuntime({
useEffect(() => {
systemRef.current = system;
imagesRef.current = images;
});
}, [images, system]);

const transport = useMemo(
() =>
Expand Down Expand Up @@ -144,8 +155,27 @@ export function AiChat({
userName,
canEdit = false,
}: AiChatProps) {
const { user } = useContext(AppContext);
const [mode, setMode] = useState<ChatMode>("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<SelectableAiModel | null>(
null,
);
const selectedAiModel = manualAiModel ?? defaultAiModel;
const modelSelectorOptions = useMemo(
() =>
SELECTABLE_AI_MODEL_OPTIONS.map((model) => ({
id: model.value,
name: model.label,
icon: <AiModelProviderIcon provider={model.provider} />,
})),
[],
);

useEffect(() => {
if (!open) {
Expand Down Expand Up @@ -196,9 +226,9 @@ export function AiChat({
>
<div className="flex items-center gap-2">
<BotMessageSquareIcon className="text-primary size-4" />
<span className="text-sm font-semibold">Asystent AI</span>
<span className="truncate text-sm font-semibold">Asystent AI</span>
</div>
<div className="flex items-center gap-0.5">
<div className="ml-2 flex min-w-0 items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
Expand Down Expand Up @@ -246,7 +276,24 @@ export function AiChat({
userName={userName}
canEdit={canEdit}
>
<Thread />
<Thread
composerStart={
canSelectAiModel ? (
<ModelSelector
models={modelSelectorOptions}
value={selectedAiModel}
onValueChange={(value) => {
if (isSelectableAiModel(value)) {
setManualAiModel(value);
}
}}
size="sm"
className="max-w-[calc(100vw-7rem)] min-w-0 rounded-full px-2.5 text-xs sm:w-40"
contentClassName="min-w-56"
/>
) : null
}
/>
</ChatRuntime>
</div>
</div>
Expand Down
42 changes: 42 additions & 0 deletions src/components/ai/ai-model-provider-icon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Icon
aria-hidden="true"
className={cn("size-4", providerClassName, className)}
/>
);
}
124 changes: 124 additions & 0 deletions src/components/assistant-ui/model-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import { useAui } from "@assistant-ui/react";
import { memo, useEffect, useState } from "react";
import type { ReactNode } from "react";

import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";

export interface ModelOption {
id: string;
name: string;
icon?: ReactNode;
disabled?: boolean;
}

export interface ModelSelectorProps {
models: ModelOption[];
value?: string;
onValueChange?: (value: string) => void;
defaultValue?: string;
size?: "default" | "sm";
disabled?: boolean;
contentClassName?: string;
className?: string;
}

function ModelSelectorImplementation({
value: controlledValue,
onValueChange: controlledOnValueChange,
defaultValue,
models,
size,
disabled,
contentClassName,
className,
}: ModelSelectorProps) {
const isControlled = controlledValue !== undefined;
const [internalValue, setInternalValue] = useState(
() => defaultValue ?? models.at(0)?.id ?? "",
);
const value = isControlled ? controlledValue : internalValue;
const onValueChange = controlledOnValueChange ?? setInternalValue;
const api = useAui();
const selectedModel = models.find((model) => model.id === value);
const handleValueChange = (nextValue: string | null) => {
if (nextValue !== null) {
onValueChange(nextValue);
}
};

useEffect(() => {
const config = { config: { modelName: value } };

return api.modelContext().register({
getModelContext: () => config,
});
}, [api, value]);

return (
<Select
items={models.map((model) => ({
label: model.name,
value: model.id,
disabled: model.disabled,
}))}
value={value}
onValueChange={handleValueChange}
disabled={disabled}
>
<SelectTrigger
aria-label="Wybierz model AI"
size={size}
className={cn("aui-model-selector-trigger", className)}
title={selectedModel?.name}
>
{selectedModel?.icon === undefined ? null : (
<span className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
{selectedModel.icon}
</span>
)}
<span className="truncate font-medium">
{selectedModel?.name ?? value}
</span>
</SelectTrigger>
<SelectContent
alignItemWithTrigger
className={cn("min-w-56", contentClassName)}
>
<SelectGroup>
{models.map((model) => (
<SelectItem
key={model.id}
value={model.id}
disabled={model.disabled}
className="py-2"
>
<div className="flex min-w-0 items-center gap-2">
{model.icon === undefined ? null : (
<span className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
{model.icon}
</span>
)}
<span className="truncate font-medium">{model.name}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}

const ModelSelector = memo(ModelSelectorImplementation);

ModelSelector.displayName = "ModelSelector";

export { ModelSelector };
Loading
Loading