fix: title generation + ai sdk upgrade (#1392)

This commit is contained in:
josh 2026-01-15 16:06:42 +00:00 committed by GitHub
parent f19d3d4071
commit 9d5d8a3ea7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1422 additions and 2360 deletions

18
.vscode/settings.json vendored
View file

@ -31,5 +31,23 @@
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit", "source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit" "source.organizeImports.biome": "explicit"
},
"[html]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[vue]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[svelte]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[mdx]": {
"editor.defaultFormatter": "biomejs.biome"
} }
} }

View file

@ -1,2 +1 @@
// biome-ignore lint/performance/noBarrelFile: "Required"
export { GET, POST } from "@/app/(auth)/auth"; export { GET, POST } from "@/app/(auth)/auth";

View file

@ -16,7 +16,6 @@ declare module "next-auth" {
} & DefaultSession["user"]; } & DefaultSession["user"];
} }
// biome-ignore lint/nursery/useConsistentTypeDefinitions: "Required"
interface User { interface User {
id?: string; id?: string;
email?: string | null; email?: string | null;

View file

@ -22,13 +22,15 @@ export async function generateTitleFromUserMessage({
}: { }: {
message: UIMessage; message: UIMessage;
}) { }) {
const { text: title } = await generateText({ const { text } = await generateText({
model: getTitleModel(), model: getTitleModel(),
system: titlePrompt, system: titlePrompt,
prompt: getTextFromMessage(message), prompt: getTextFromMessage(message),
}); });
return text
return title; .replace(/^[#*"\s]+/, "")
.replace(/["]+$/, "")
.trim();
} }
export async function deleteTrailingMessages({ id }: { id: string }) { export async function deleteTrailingMessages({ id }: { id: string }) {

View file

@ -1,113 +1,3 @@
import { createUIMessageStream, JsonToSseTransformStream } from "ai"; export function GET() {
import { differenceInSeconds } from "date-fns"; return new Response(null, { status: 204 });
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
getMessagesByChatId,
getStreamIdsByChatId,
} from "@/lib/db/queries";
import type { Chat } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types";
import { getStreamContext } from "../../route";
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: chatId } = await params;
const streamContext = getStreamContext();
const resumeRequestedAt = new Date();
if (!streamContext) {
return new Response(null, { status: 204 });
}
if (!chatId) {
return new ChatSDKError("bad_request:api").toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:chat").toResponse();
}
let chat: Chat | null;
try {
chat = await getChatById({ id: chatId });
} catch {
return new ChatSDKError("not_found:chat").toResponse();
}
if (!chat) {
return new ChatSDKError("not_found:chat").toResponse();
}
if (chat.visibility === "private" && chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
const streamIds = await getStreamIdsByChatId({ chatId });
if (!streamIds.length) {
return new ChatSDKError("not_found:stream").toResponse();
}
const recentStreamId = streamIds.at(-1);
if (!recentStreamId) {
return new ChatSDKError("not_found:stream").toResponse();
}
const emptyDataStream = createUIMessageStream<ChatMessage>({
// biome-ignore lint/suspicious/noEmptyBlockStatements: "Needs to exist"
execute: () => {},
});
const stream = await streamContext.resumableStream(recentStreamId, () =>
emptyDataStream.pipeThrough(new JsonToSseTransformStream())
);
/*
* For when the generation is streaming during SSR
* but the resumable stream has concluded at this point.
*/
if (!stream) {
const messages = await getMessagesByChatId({ id: chatId });
const mostRecentMessage = messages.at(-1);
if (!mostRecentMessage) {
return new Response(emptyDataStream, { status: 200 });
}
if (mostRecentMessage.role !== "assistant") {
return new Response(emptyDataStream, { status: 200 });
}
const messageCreatedAt = new Date(mostRecentMessage.createdAt);
if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) {
return new Response(emptyDataStream, { status: 200 });
}
const restoredStream = createUIMessageStream<ChatMessage>({
execute: ({ writer }) => {
writer.write({
type: "data-appendMessage",
data: JSON.stringify(mostRecentMessage),
transient: true,
});
},
});
return new Response(
restoredStream.pipeThrough(new JsonToSseTransformStream()),
{ status: 200 }
);
}
return new Response(stream, { status: 200 });
} }

View file

@ -2,16 +2,13 @@ import { geolocation } from "@vercel/functions";
import { import {
convertToModelMessages, convertToModelMessages,
createUIMessageStream, createUIMessageStream,
JsonToSseTransformStream, createUIMessageStreamResponse,
smoothStream, generateId,
stepCountIs, stepCountIs,
streamText, streamText,
} from "ai"; } from "ai";
import { after } from "next/server"; import { after } from "next/server";
import { import { createResumableStreamContext } from "resumable-stream";
createResumableStreamContext,
type ResumableStreamContext,
} from "resumable-stream";
import { auth, type UserType } from "@/app/(auth)/auth"; import { auth, type UserType } from "@/app/(auth)/auth";
import { entitlementsByUserType } from "@/lib/ai/entitlements"; import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts"; import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
@ -41,28 +38,16 @@ import { type PostRequestBody, postRequestBodySchema } from "./schema";
export const maxDuration = 60; export const maxDuration = 60;
let globalStreamContext: ResumableStreamContext | null = null; function getStreamContext() {
try {
export function getStreamContext() { return createResumableStreamContext({ waitUntil: after });
if (!globalStreamContext) { } catch (_) {
try { return null;
globalStreamContext = createResumableStreamContext({
waitUntil: after,
});
} catch (error: any) {
if (error.message.includes("REDIS_URL")) {
console.log(
" > Resumable streams are disabled due to missing REDIS_URL"
);
} else {
console.error(error);
}
}
} }
return globalStreamContext;
} }
export { getStreamContext };
export async function POST(request: Request) { export async function POST(request: Request) {
let requestBody: PostRequestBody; let requestBody: PostRequestBody;
@ -94,7 +79,6 @@ export async function POST(request: Request) {
return new ChatSDKError("rate_limit:chat").toResponse(); return new ChatSDKError("rate_limit:chat").toResponse();
} }
// Check if this is a tool approval flow (all messages sent)
const isToolApprovalFlow = Boolean(messages); const isToolApprovalFlow = Boolean(messages);
const chat = await getChatById({ id }); const chat = await getChatById({ id });
@ -105,24 +89,19 @@ export async function POST(request: Request) {
if (chat.userId !== session.user.id) { if (chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse(); return new ChatSDKError("forbidden:chat").toResponse();
} }
// Only fetch messages if chat already exists and not tool approval
if (!isToolApprovalFlow) { if (!isToolApprovalFlow) {
messagesFromDb = await getMessagesByChatId({ id }); messagesFromDb = await getMessagesByChatId({ id });
} }
} else if (message?.role === "user") { } else if (message?.role === "user") {
// Save chat immediately with placeholder title
await saveChat({ await saveChat({
id, id,
userId: session.user.id, userId: session.user.id,
title: "New chat", title: "New chat",
visibility: selectedVisibilityType, visibility: selectedVisibilityType,
}); });
// Start title generation in parallel (don't await)
titlePromise = generateTitleFromUserMessage({ message }); titlePromise = generateTitleFromUserMessage({ message });
} }
// Use all messages for tool approval, otherwise DB messages + new message
const uiMessages = isToolApprovalFlow const uiMessages = isToolApprovalFlow
? (messages as ChatMessage[]) ? (messages as ChatMessage[])
: [...convertToUIMessages(messagesFromDb), message as ChatMessage]; : [...convertToUIMessages(messagesFromDb), message as ChatMessage];
@ -136,7 +115,6 @@ export async function POST(request: Request) {
country, country,
}; };
// Only save user messages to the database (not tool approval responses)
if (message?.role === "user") { if (message?.role === "user") {
await saveMessages({ await saveMessages({
messages: [ messages: [
@ -152,29 +130,19 @@ export async function POST(request: Request) {
}); });
} }
const streamId = generateUUID(); const isReasoningModel =
await createStreamId({ streamId, chatId: id }); selectedChatModel.includes("reasoning") ||
selectedChatModel.includes("thinking");
const modelMessages = await convertToModelMessages(uiMessages);
const stream = createUIMessageStream({ const stream = createUIMessageStream({
// Pass original messages for tool approval continuation
originalMessages: isToolApprovalFlow ? uiMessages : undefined, originalMessages: isToolApprovalFlow ? uiMessages : undefined,
execute: async ({ writer: dataStream }) => { execute: async ({ writer: dataStream }) => {
// Handle title generation in parallel
if (titlePromise) {
titlePromise.then((title) => {
updateChatTitleById({ chatId: id, title });
dataStream.write({ type: "data-chat-title", data: title });
});
}
const isReasoningModel =
selectedChatModel.includes("reasoning") ||
selectedChatModel.includes("thinking");
const result = streamText({ const result = streamText({
model: getLanguageModel(selectedChatModel), model: getLanguageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel, requestHints }), system: systemPrompt({ selectedChatModel, requestHints }),
messages: await convertToModelMessages(uiMessages), messages: modelMessages,
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
experimental_activeTools: isReasoningModel experimental_activeTools: isReasoningModel
? [] ? []
@ -184,9 +152,6 @@ export async function POST(request: Request) {
"updateDocument", "updateDocument",
"requestSuggestions", "requestSuggestions",
], ],
experimental_transform: isReasoningModel
? undefined
: smoothStream({ chunking: "word" }),
providerOptions: isReasoningModel providerOptions: isReasoningModel
? { ? {
anthropic: { anthropic: {
@ -198,10 +163,7 @@ export async function POST(request: Request) {
getWeather, getWeather,
createDocument: createDocument({ session, dataStream }), createDocument: createDocument({ session, dataStream }),
updateDocument: updateDocument({ session, dataStream }), updateDocument: updateDocument({ session, dataStream }),
requestSuggestions: requestSuggestions({ requestSuggestions: requestSuggestions({ session, dataStream }),
session,
dataStream,
}),
}, },
experimental_telemetry: { experimental_telemetry: {
isEnabled: isProductionEnvironment, isEnabled: isProductionEnvironment,
@ -209,28 +171,25 @@ export async function POST(request: Request) {
}, },
}); });
result.consumeStream(); dataStream.merge(result.toUIMessageStream({ sendReasoning: true }));
dataStream.merge( if (titlePromise) {
result.toUIMessageStream({ const title = await titlePromise;
sendReasoning: true, dataStream.write({ type: "data-chat-title", data: title });
}) updateChatTitleById({ chatId: id, title });
); }
}, },
generateId: generateUUID, generateId: generateUUID,
onFinish: async ({ messages: finishedMessages }) => { onFinish: async ({ messages: finishedMessages }) => {
if (isToolApprovalFlow) { if (isToolApprovalFlow) {
// For tool approval, update existing messages (tool state changed) and save new ones
for (const finishedMsg of finishedMessages) { for (const finishedMsg of finishedMessages) {
const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id); const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id);
if (existingMsg) { if (existingMsg) {
// Update existing message with new parts (tool state changed)
await updateMessage({ await updateMessage({
id: finishedMsg.id, id: finishedMsg.id,
parts: finishedMsg.parts, parts: finishedMsg.parts,
}); });
} else { } else {
// Save new message
await saveMessages({ await saveMessages({
messages: [ messages: [
{ {
@ -246,7 +205,6 @@ export async function POST(request: Request) {
} }
} }
} else if (finishedMessages.length > 0) { } else if (finishedMessages.length > 0) {
// Normal flow - save all finished messages
await saveMessages({ await saveMessages({
messages: finishedMessages.map((currentMessage) => ({ messages: finishedMessages.map((currentMessage) => ({
id: currentMessage.id, id: currentMessage.id,
@ -259,28 +217,30 @@ export async function POST(request: Request) {
}); });
} }
}, },
onError: () => { onError: () => "Oops, an error occurred!",
return "Oops, an error occurred!";
},
}); });
const streamContext = getStreamContext(); return createUIMessageStreamResponse({
stream,
if (streamContext) { async consumeSseStream({ stream: sseStream }) {
try { if (!process.env.REDIS_URL) {
const resumableStream = await streamContext.resumableStream( return;
streamId,
() => stream.pipeThrough(new JsonToSseTransformStream())
);
if (resumableStream) {
return new Response(resumableStream);
} }
} catch (error) { try {
console.error("Failed to create resumable stream:", error); const streamContext = getStreamContext();
} if (streamContext) {
} const streamId = generateId();
await createStreamId({ streamId, chatId: id });
return new Response(stream.pipeThrough(new JsonToSseTransformStream())); await streamContext.createNewResumableStream(
streamId,
() => sseStream
);
}
} catch (_) {
// ignore redis errors
}
},
});
} catch (error) { } catch (error) {
const vercelId = request.headers.get("x-vercel-id"); const vercelId = request.headers.get("x-vercel-id");
@ -288,7 +248,6 @@ export async function POST(request: Request) {
return error.toResponse(); return error.toResponse();
} }
// Check for Vercel AI Gateway credit card error
if ( if (
error instanceof Error && error instanceof Error &&
error.message?.includes( error.message?.includes(

View file

@ -2,7 +2,7 @@
@import "katex/dist/katex.min.css"; @import "katex/dist/katex.min.css";
/* include utility classes in streamdown */ /* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js'; @source "../node_modules/streamdown/dist/index.js";
/* custom variant for setting dark mode programmatically */ /* custom variant for setting dark mode programmatically */
@custom-variant dark (&:is(.dark, .dark *)); @custom-variant dark (&:is(.dark, .dark *));

View file

@ -1,9 +1,23 @@
{ {
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite"], "extends": [
"ultracite/biome/core",
"ultracite/biome/next",
"ultracite/biome/react"
],
"formatter": {
"indentStyle": "space",
"indentWidth": 2
},
"files": { "files": {
"includes": [ "includes": [
"**/*", "**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"!node_modules",
"!.next",
"!ai-sdk",
"!components/ui", "!components/ui",
"!lib/utils.ts", "!lib/utils.ts",
"!hooks/use-mobile.ts" "!hooks/use-mobile.ts"
@ -12,39 +26,34 @@
"linter": { "linter": {
"rules": { "rules": {
"suspicious": { "suspicious": {
/* Needs more work to fix */
"noExplicitAny": "off", "noExplicitAny": "off",
/* Allow for Tailwind @ rules */
"noUnknownAtRules": "off", "noUnknownAtRules": "off",
/* Allowing console for debugging */
"noConsole": "off", "noConsole": "off",
/* Needed for generateUUID() */
"noBitwiseOperators": "off" "noBitwiseOperators": "off"
}, },
"style": { "style": {
/* Allowing magic numbers */
"noMagicNumbers": "off", "noMagicNumbers": "off",
"noNestedTernary": "off",
/* Needs more work to fix */ "useConsistentTypeDefinitions": "off"
"noNestedTernary": "off"
}, },
"nursery": { "nursery": {
/* Too many false positives */ "noUnnecessaryConditions": "off",
"noUnnecessaryConditions": "off" "useSortedClasses": "off"
}, },
"complexity": { "complexity": {
/* Needs more work to fix */
"noExcessiveCognitiveComplexity": "off", "noExcessiveCognitiveComplexity": "off",
/* This one has false positives. It's a bit... iffy 😉 */
"useSimplifiedLogicExpression": "off" "useSimplifiedLogicExpression": "off"
}, },
"a11y": { "a11y": {
/* Needs more work to fix */
"noSvgWithoutTitle": "off" "noSvgWithoutTitle": "off"
},
"correctness": {
"useUniqueElementIds": "off",
"useImageSize": "off"
},
"performance": {
"noBarrelFile": "off",
"useTopLevelRegex": "off"
} }
} }
} }

View file

@ -143,7 +143,7 @@ export const ChainOfThoughtStep = memo(
> >
<div className="relative mt-0.5"> <div className="relative mt-0.5">
<Icon className="size-4" /> <Icon className="size-4" />
<div className="-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border" /> <div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
</div> </div>
<div className="flex-1 space-y-2 overflow-hidden"> <div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div> <div>{label}</div>

View file

@ -1,178 +0,0 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
type HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
const lineNumberTransformer: ShikiTransformer = {
name: "line-numbers",
line(node, line) {
node.children.unshift({
type: "element",
tagName: "span",
properties: {
className: [
"inline-block",
"min-w-10",
"mr-4",
"text-right",
"select-none",
"text-muted-foreground",
],
},
children: [{ type: "text", value: String(line) }],
});
},
};
export async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false
) {
const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer]
: [];
return await Promise.all([
codeToHtml(code, {
lang: language,
theme: "one-light",
transformers,
}),
codeToHtml(code, {
lang: language,
theme: "one-dark-pro",
transformers,
}),
]);
}
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const [html, setHtml] = useState<string>("");
const [darkHtml, setDarkHtml] = useState<string>("");
const mounted = useRef(false);
useEffect(() => {
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
if (!mounted.current) {
setHtml(light);
setDarkHtml(dark);
mounted.current = true;
}
});
return () => {
mounted.current = false;
};
}, [code, language, showLineNumbers]);
return (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
{...props}
>
<div className="relative">
<div
className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
<div
className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }}
/>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View file

@ -12,7 +12,6 @@ export const Image = ({
mediaType, mediaType,
...props ...props
}: ImageProps) => ( }: ImageProps) => (
// biome-ignore lint/nursery/useImageSize: dynamic base64 content
// biome-ignore lint/performance/noImgElement: base64 data URLs require native img // biome-ignore lint/performance/noImgElement: base64 data URLs require native img
<img <img
{...props} {...props}

View file

@ -186,7 +186,7 @@ export const ModelSelectorLogoGroup = ({
}: ModelSelectorLogoGroupProps) => ( }: ModelSelectorLogoGroupProps) => (
<div <div
className={cn( className={cn(
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground", "flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
className className
)} )}
{...props} {...props}

View file

@ -154,8 +154,7 @@ export function PromptInputProvider({
(FileUIPart & { id: string })[] (FileUIPart & { id: string })[]
>([]); >([]);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
// biome-ignore lint/suspicious/noEmptyBlockStatements: noop initializer const openRef = useRef<() => void>(() => undefined);
const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => { const add = useCallback((files: File[] | FileList) => {
const incoming = Array.from(files); const incoming = Array.from(files);
@ -309,18 +308,18 @@ export function PromptInputAttachment({
{...props} {...props}
> >
<div className="relative size-5 shrink-0"> <div className="relative size-5 shrink-0">
<div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0"> <div className="flex overflow-hidden absolute inset-0 justify-center items-center rounded transition-opacity size-5 bg-background group-hover:opacity-0">
{isImage ? ( {isImage ? (
/* biome-ignore lint/performance/noImgElement: dynamic user uploads */ /* biome-ignore lint/performance/noImgElement: dynamic user uploads */
<img <img
alt={filename || "attachment"} alt={filename || "attachment"}
className="size-5 object-cover" className="object-cover size-5"
height={20} height={20}
src={data.url} src={data.url}
width={20} width={20}
/> />
) : ( ) : (
<div className="flex size-5 items-center justify-center text-muted-foreground"> <div className="flex justify-center items-center size-5 text-muted-foreground">
<PaperclipIcon className="size-3" /> <PaperclipIcon className="size-3" />
</div> </div>
)} )}
@ -343,14 +342,14 @@ export function PromptInputAttachment({
<span className="flex-1 truncate">{attachmentLabel}</span> <span className="flex-1 truncate">{attachmentLabel}</span>
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
<PromptInputHoverCardContent className="w-auto p-2"> <PromptInputHoverCardContent className="p-2 w-auto">
<div className="w-auto space-y-3"> <div className="space-y-3 w-auto">
{isImage && ( {isImage && (
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border"> <div className="flex overflow-hidden justify-center items-center w-96 max-h-96 rounded-md border">
{/* biome-ignore lint/performance/noImgElement: dynamic user uploads */} {/* biome-ignore lint/performance/noImgElement: dynamic user uploads */}
<img <img
alt={filename || "attachment preview"} alt={filename || "attachment preview"}
className="max-h-full max-w-full object-contain" className="object-contain max-w-full max-h-full"
height={384} height={384}
src={data.url} src={data.url}
width={448} width={448}
@ -359,11 +358,11 @@ export function PromptInputAttachment({
)} )}
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="min-w-0 flex-1 space-y-1 px-0.5"> <div className="min-w-0 flex-1 space-y-1 px-0.5">
<h4 className="truncate font-semibold text-sm leading-none"> <h4 className="text-sm font-semibold leading-none truncate">
{filename || (isImage ? "Image" : "Attachment")} {filename || (isImage ? "Image" : "Attachment")}
</h4> </h4>
{data.mediaType && ( {data.mediaType && (
<p className="truncate font-mono text-muted-foreground text-xs"> <p className="font-mono text-xs truncate text-muted-foreground">
{data.mediaType} {data.mediaType}
</p> </p>
)} )}
@ -395,7 +394,7 @@ export function PromptInputAttachments({
return ( return (
<div <div
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)} className={cn("flex flex-wrap gap-2 items-center p-3 w-full", className)}
{...props} {...props}
> >
{attachments.files.map((file) => ( {attachments.files.map((file) => (
@ -913,7 +912,7 @@ export const PromptInputTextarea = ({
return ( return (
<InputGroupTextarea <InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)} className={cn("max-h-48 field-sizing-content min-h-16", className)}
name="message" name="message"
onCompositionEnd={() => setIsComposing(false)} onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)} onCompositionStart={() => setIsComposing(true)}
@ -937,7 +936,7 @@ export const PromptInputHeader = ({
}: PromptInputHeaderProps) => ( }: PromptInputHeaderProps) => (
<InputGroupAddon <InputGroupAddon
align="block-end" align="block-end"
className={cn("order-first flex-wrap gap-1", className)} className={cn("flex-wrap order-first gap-1", className)}
{...props} {...props}
/> />
); );
@ -953,7 +952,7 @@ export const PromptInputFooter = ({
}: PromptInputFooterProps) => ( }: PromptInputFooterProps) => (
<InputGroupAddon <InputGroupAddon
align="block-end" align="block-end"
className={cn("justify-between gap-1", className)} className={cn("gap-1 justify-between", className)}
{...props} {...props}
/> />
); );
@ -964,7 +963,7 @@ export const PromptInputTools = ({
className, className,
...props ...props
}: PromptInputToolsProps) => ( }: PromptInputToolsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} /> <div className={cn("flex gap-1 items-center", className)} {...props} />
); );
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>; export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
@ -1046,7 +1045,7 @@ export const PromptInputSubmit = ({
let Icon = <CornerDownLeftIcon className="size-4" />; let Icon = <CornerDownLeftIcon className="size-4" />;
if (status === "submitted") { if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />; Icon = <Loader2Icon className="animate-spin size-4" />;
} else if (status === "streaming") { } else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />; Icon = <SquareIcon className="size-4" />;
} else if (status === "error") { } else if (status === "error") {
@ -1111,7 +1110,6 @@ interface SpeechRecognitionErrorEvent extends Event {
} }
declare global { declare global {
// biome-ignore lint/nursery/useConsistentTypeDefinitions: global augmentation requires interface
interface Window { interface Window {
SpeechRecognition: { SpeechRecognition: {
new (): SpeechRecognition; new (): SpeechRecognition;
@ -1244,7 +1242,7 @@ export const PromptInputSelectTrigger = ({
}: PromptInputSelectTriggerProps) => ( }: PromptInputSelectTriggerProps) => (
<SelectTrigger <SelectTrigger
className={cn( className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors", "font-medium bg-transparent border-none shadow-none transition-colors text-muted-foreground",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground", "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
className className
)} )}
@ -1332,7 +1330,7 @@ export const PromptInputTabLabel = ({
}: PromptInputTabLabelProps) => ( }: PromptInputTabLabelProps) => (
<h3 <h3
className={cn( className={cn(
"mb-2 px-3 font-medium text-muted-foreground text-xs", "px-3 mb-2 text-xs font-medium text-muted-foreground",
className className
)} )}
{...props} {...props}
@ -1356,7 +1354,7 @@ export const PromptInputTabItem = ({
}: PromptInputTabItemProps) => ( }: PromptInputTabItemProps) => (
<div <div
className={cn( className={cn(
"flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent", "flex gap-2 items-center px-3 py-2 text-xs hover:bg-accent",
className className
)} )}
{...props} {...props}

View file

@ -187,7 +187,7 @@ export const QueueList = ({
className, className,
...props ...props
}: QueueListProps) => ( }: QueueListProps) => (
<ScrollArea className={cn("-mb-1 mt-2", className)} {...props}> <ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
<div className="max-h-40 pr-4"> <div className="max-h-40 pr-4">
<ul>{children}</ul> <ul>{children}</ul>
</div> </div>
@ -242,7 +242,7 @@ export const QueueSectionLabel = ({
...props ...props
}: QueueSectionLabelProps) => ( }: QueueSectionLabelProps) => (
<span className={cn("flex items-center gap-2", className)} {...props}> <span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" /> <ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
{icon} {icon}
<span> <span>
{count} {label} {count} {label}

View file

@ -18,7 +18,6 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>; export type ToolProps = ComponentProps<typeof Collapsible>;
@ -111,9 +110,9 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide"> <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters Parameters
</h4> </h4>
<div className="rounded-md bg-muted/50"> <pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" /> {JSON.stringify(input, null, 2)}
</div> </pre>
</div> </div>
); );
@ -132,15 +131,21 @@ export const ToolOutput = ({
return null; return null;
} }
let Output = <div>{output as ReactNode}</div>; const renderOutput = () => {
if (typeof output === "object" && !isValidElement(output)) {
if (typeof output === "object" && !isValidElement(output)) { return (
Output = ( <pre className="overflow-x-auto p-3 font-mono text-xs">
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" /> {JSON.stringify(output, null, 2)}
); </pre>
} else if (typeof output === "string") { );
Output = <CodeBlock code={output} language="json" />; }
} if (typeof output === "string") {
return (
<pre className="overflow-x-auto p-3 font-mono text-xs">{output}</pre>
);
}
return <div className="p-3">{output as ReactNode}</div>;
};
return ( return (
<div className={cn("space-y-2 p-4", className)} {...props}> <div className={cn("space-y-2 p-4", className)} {...props}>
@ -155,8 +160,8 @@ export const ToolOutput = ({
: "bg-muted/50 text-foreground" : "bg-muted/50 text-foreground"
)} )}
> >
{errorText && <div>{errorText}</div>} {errorText && <div className="p-3">{errorText}</div>}
{Output} {!errorText && renderOutput()}
</div> </div>
</div> </div>
); );

View file

@ -89,13 +89,9 @@ export function Chat({
} = useChat<ChatMessage>({ } = useChat<ChatMessage>({
id, id,
messages: initialMessages, messages: initialMessages,
experimental_throttle: 100,
generateId: generateUUID, generateId: generateUUID,
// Auto-continue after tool approval (only for APPROVED tools)
// Denied tools don't need server continuation - state is saved on next user message
sendAutomaticallyWhen: ({ messages: currentMessages }) => { sendAutomaticallyWhen: ({ messages: currentMessages }) => {
const lastMessage = currentMessages.at(-1); const lastMessage = currentMessages.at(-1);
// Only continue if a tool was APPROVED (not denied)
const shouldContinue = const shouldContinue =
lastMessage?.parts?.some( lastMessage?.parts?.some(
(part) => (part) =>
@ -111,10 +107,6 @@ export function Chat({
fetch: fetchWithErrorHandlers, fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) { prepareSendMessagesRequest(request) {
const lastMessage = request.messages.at(-1); const lastMessage = request.messages.at(-1);
// Check if this is a tool approval continuation:
// - Last message is NOT a user message (meaning no new user input)
// - OR any message has tool parts that were responded to (approved or denied)
const isToolApprovalContinuation = const isToolApprovalContinuation =
lastMessage?.role !== "user" || lastMessage?.role !== "user" ||
request.messages.some((msg) => request.messages.some((msg) =>
@ -129,7 +121,6 @@ export function Chat({
return { return {
body: { body: {
id: request.id, id: request.id,
// Send all messages for tool approval continuation, otherwise just the last user message
...(isToolApprovalContinuation ...(isToolApprovalContinuation
? { messages: request.messages } ? { messages: request.messages }
: { message: lastMessage }), : { message: lastMessage }),
@ -148,7 +139,6 @@ export function Chat({
}, },
onError: (error) => { onError: (error) => {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
// Check if it's a credit card error
if ( if (
error.message?.includes("AI Gateway requires a valid credit card") error.message?.includes("AI Gateway requires a valid credit card")
) { ) {

View file

@ -165,7 +165,6 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
{consoleOutput.contents.map((content, contentIndex) => {consoleOutput.contents.map((content, contentIndex) =>
content.type === "image" ? ( content.type === "image" ? (
<picture key={`${consoleOutput.id}-${contentIndex}`}> <picture key={`${consoleOutput.id}-${contentIndex}`}>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img <img
alt="output" alt="output"
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md" className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"

View file

@ -1,154 +0,0 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
{...props}
>
<div className="relative">
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: "font-mono text-sm",
}}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
overflowX: "auto",
overflowWrap: "break-word",
wordBreak: "break-all",
}}
language={language}
lineNumberStyle={{
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
}}
showLineNumbers={showLineNumbers}
style={oneLight}
>
{code}
</SyntaxHighlighter>
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: "font-mono text-sm",
}}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
overflowX: "auto",
overflowWrap: "break-word",
wordBreak: "break-all",
}}
language={language}
lineNumberStyle={{
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
}}
showLineNumbers={showLineNumbers}
style={oneDark}
>
{code}
</SyntaxHighlighter>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View file

@ -49,7 +49,7 @@ export const ConversationScrollButton = ({
!isAtBottom && ( !isAtBottom && (
<Button <Button
className={cn( className={cn(
"-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full shadow-lg", "absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full shadow-lg",
className className
)} )}
onClick={handleScrollToBottom} onClick={handleScrollToBottom}

View file

@ -12,8 +12,7 @@ export const Image = ({
mediaType, mediaType,
...props ...props
}: ImageProps) => ( }: ImageProps) => (
// biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" // biome-ignore lint/performance/noImgElement: base64 data URLs require native img
// biome-ignore lint/performance/noImgElement: "Generated image without explicit size"
<img <img
{...props} {...props}
alt={props.alt} alt={props.alt}

View file

@ -50,22 +50,25 @@ export const PromptInputTextarea = ({
}: PromptInputTextareaProps) => { }: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => { const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) { if (e.nativeEvent.isComposing) {
return; return;
} }
if (e.shiftKey) { if (e.shiftKey) {
// Allow newline
return; return;
} }
// Submit on Enter (without Shift)
e.preventDefault(); e.preventDefault();
const form = e.currentTarget.form; const form = e.currentTarget.form;
if (form) { const submitButton = form?.querySelector(
form.requestSubmit(); 'button[type="submit"]'
) as HTMLButtonElement | null;
if (submitButton?.disabled) {
return;
} }
form?.requestSubmit();
} }
}; };

View file

@ -6,14 +6,16 @@ import { cn } from "@/lib/utils";
type ResponseProps = ComponentProps<typeof Streamdown>; type ResponseProps = ComponentProps<typeof Streamdown>;
export const Response = ({ className, ...props }: ResponseProps) => ( export function Response({ className, children, ...props }: ResponseProps) {
<Streamdown return (
className={cn( <Streamdown
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto", className={cn(
className "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
)} className
{...props} )}
/> {...props}
); >
{children}
Response.displayName = "Response"; </Streamdown>
);
}

View file

@ -17,7 +17,6 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>; export type ToolProps = ComponentProps<typeof Collapsible>;
@ -111,9 +110,9 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide"> <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters Parameters
</h4> </h4>
<div className="rounded-md bg-muted/50"> <pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" /> {JSON.stringify(input, null, 2)}
</div> </pre>
</div> </div>
); );

View file

@ -34,7 +34,6 @@ export function ImageEditor({
</div> </div>
) : ( ) : (
<picture> <picture>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img <img
alt={title} alt={title}
className={cn("h-fit w-full max-w-[800px]", { className={cn("h-fit w-full max-w-[800px]", {

View file

@ -51,7 +51,7 @@ export function PureMessageActions({
<div className="relative"> <div className="relative">
{setMode && ( {setMode && (
<Action <Action
className="-left-10 absolute top-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100" className="absolute top-0 -left-10 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
data-testid="message-edit-button" data-testid="message-edit-button"
onClick={() => setMode("edit")} onClick={() => setMode("edit")}
tooltip="Edit" tooltip="Edit"

View file

@ -108,14 +108,18 @@ const PurePreviewMessage = ({
const { type } = part; const { type } = part;
const key = `message-${message.id}-part-${index}`; const key = `message-${message.id}-part-${index}`;
if (type === "reasoning" && part.text?.trim().length > 0) { if (type === "reasoning") {
return ( const hasContent = part.text?.trim().length > 0;
<MessageReasoning const isStreaming = "state" in part && part.state === "streaming";
isLoading={isLoading} if (hasContent || isStreaming) {
key={key} return (
reasoning={part.text} <MessageReasoning
/> isLoading={isLoading || isStreaming}
); key={key}
reasoning={part.text || ""}
/>
);
}
} }
if (type === "text") { if (type === "text") {

View file

@ -91,7 +91,7 @@ function PureMessages({
<button <button
aria-label="Scroll to bottom" aria-label="Scroll to bottom"
className={`-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${ className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
isAtBottom isAtBottom
? "pointer-events-none scale-0 opacity-0" ? "pointer-events-none scale-0 opacity-0"
: "pointer-events-auto scale-100 opacity-100" : "pointer-events-auto scale-100 opacity-100"

View file

@ -308,7 +308,7 @@ function PureMultimodalInput({
)} )}
<input <input
className="-top-4 -left-4 pointer-events-none fixed size-0.5 opacity-0" className="pointer-events-none fixed -top-4 -left-4 size-0.5 opacity-0"
multiple multiple
onChange={handleFileChange} onChange={handleFileChange}
ref={fileInputRef} ref={fileInputRef}
@ -320,6 +320,9 @@ function PureMultimodalInput({
className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50" className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (!input.trim() && attachments.length === 0) {
return;
}
if (status !== "ready") { if (status !== "ready") {
toast.error("Please wait for the model to finish its response!"); toast.error("Please wait for the model to finish its response!");
} else { } else {

View file

@ -27,7 +27,7 @@ export const Suggestion = ({
{isExpanded ? ( {isExpanded ? (
<motion.div <motion.div
animate={{ opacity: 1, y: -20 }} animate={{ opacity: 1, y: -20 }}
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl" className="absolute -right-12 z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl md:-right-16"
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
key={suggestion.id} key={suggestion.id}
@ -61,7 +61,7 @@ export const Suggestion = ({
) : ( ) : (
<motion.div <motion.div
className={cn("cursor-pointer p-1 text-muted-foreground", { className={cn("cursor-pointer p-1 text-muted-foreground", {
"-right-8 absolute": artifactKind === "text", "absolute -right-8": artifactKind === "text",
"sticky top-0 right-4": artifactKind === "code", "sticky top-0 right-4": artifactKind === "code",
})} })}
onClick={() => { onClick={() => {

View file

@ -123,10 +123,17 @@ export const updateDocumentPrompt = (
${currentContent}`; ${currentContent}`;
}; };
export const titlePrompt = `Generate a very short chat title (2-5 words max) based on the user's message. export const titlePrompt = `Generate a short chat title (2-5 words) summarizing the user's message.
Rules:
- Maximum 30 characters Output ONLY the title text. No prefixes, no formatting.
- No quotes, colons, hashtags, or markdown
- Just the topic/intent, not a full sentence Examples:
- If the message is a greeting like "hi" or "hello", respond with just "New conversation" - "what's the weather in nyc" Weather in NYC
- Be concise: "Weather in NYC" not "User asking about the weather in New York City"`; - "help me write an essay about space" Space Essay Help
- "hi" New Conversation
- "debug my python code" Python Debugging
Bad outputs (never do this):
- "# Space Essay" (no hashtags)
- "Title: Weather" (no prefixes)
- ""NYC Weather"" (no quotes)`;

View file

@ -51,7 +51,7 @@ export function getTitleModel() {
if (isTestEnvironment && myProvider) { if (isTestEnvironment && myProvider) {
return myProvider.languageModel("title-model"); return myProvider.languageModel("title-model");
} }
return gateway.languageModel("anthropic/claude-haiku-4.5"); return gateway.languageModel("google/gemini-2.5-flash-lite");
} }
export function getArtifactModel() { export function getArtifactModel() {

View file

@ -0,0 +1 @@
ALTER TABLE "Chat" DROP COLUMN IF EXISTS "lastContext";

View file

@ -0,0 +1,506 @@
{
"id": "31934f42-f6af-42a3-9320-b2e86fb67e81",
"prevId": "097660a7-976a-4b3e-8ebb-79312e3ece6f",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.Chat": {
"name": "Chat",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"visibility": {
"name": "visibility",
"type": "varchar",
"primaryKey": false,
"notNull": true,
"default": "'private'"
}
},
"indexes": {},
"foreignKeys": {
"Chat_userId_User_id_fk": {
"name": "Chat_userId_User_id_fk",
"tableFrom": "Chat",
"tableTo": "User",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Document": {
"name": "Document",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"text": {
"name": "text",
"type": "varchar",
"primaryKey": false,
"notNull": true,
"default": "'text'"
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Document_userId_User_id_fk": {
"name": "Document_userId_User_id_fk",
"tableFrom": "Document",
"tableTo": "User",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Document_id_createdAt_pk": {
"name": "Document_id_createdAt_pk",
"columns": ["id", "createdAt"]
}
},
"uniqueConstraints": {}
},
"public.Message_v2": {
"name": "Message_v2",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"parts": {
"name": "parts",
"type": "json",
"primaryKey": false,
"notNull": true
},
"attachments": {
"name": "attachments",
"type": "json",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Message_v2_chatId_Chat_id_fk": {
"name": "Message_v2_chatId_Chat_id_fk",
"tableFrom": "Message_v2",
"tableTo": "Chat",
"columnsFrom": ["chatId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Message": {
"name": "Message",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "json",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Message_chatId_Chat_id_fk": {
"name": "Message_chatId_Chat_id_fk",
"tableFrom": "Message",
"tableTo": "Chat",
"columnsFrom": ["chatId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Stream": {
"name": "Stream",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Stream_chatId_Chat_id_fk": {
"name": "Stream_chatId_Chat_id_fk",
"tableFrom": "Stream",
"tableTo": "Chat",
"columnsFrom": ["chatId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Stream_id_pk": {
"name": "Stream_id_pk",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"public.Suggestion": {
"name": "Suggestion",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"documentId": {
"name": "documentId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"documentCreatedAt": {
"name": "documentCreatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"originalText": {
"name": "originalText",
"type": "text",
"primaryKey": false,
"notNull": true
},
"suggestedText": {
"name": "suggestedText",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isResolved": {
"name": "isResolved",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Suggestion_userId_User_id_fk": {
"name": "Suggestion_userId_User_id_fk",
"tableFrom": "Suggestion",
"tableTo": "User",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk": {
"name": "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk",
"tableFrom": "Suggestion",
"tableTo": "Document",
"columnsFrom": ["documentId", "documentCreatedAt"],
"columnsTo": ["id", "createdAt"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Suggestion_id_pk": {
"name": "Suggestion_id_pk",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"password": {
"name": "password",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Vote_v2": {
"name": "Vote_v2",
"schema": "",
"columns": {
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"messageId": {
"name": "messageId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"isUpvoted": {
"name": "isUpvoted",
"type": "boolean",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Vote_v2_chatId_Chat_id_fk": {
"name": "Vote_v2_chatId_Chat_id_fk",
"tableFrom": "Vote_v2",
"tableTo": "Chat",
"columnsFrom": ["chatId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"Vote_v2_messageId_Message_v2_id_fk": {
"name": "Vote_v2_messageId_Message_v2_id_fk",
"tableFrom": "Vote_v2",
"tableTo": "Message_v2",
"columnsFrom": ["messageId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Vote_v2_chatId_messageId_pk": {
"name": "Vote_v2_chatId_messageId_pk",
"columns": ["chatId", "messageId"]
}
},
"uniqueConstraints": {}
},
"public.Vote": {
"name": "Vote",
"schema": "",
"columns": {
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"messageId": {
"name": "messageId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"isUpvoted": {
"name": "isUpvoted",
"type": "boolean",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Vote_chatId_Chat_id_fk": {
"name": "Vote_chatId_Chat_id_fk",
"tableFrom": "Vote",
"tableTo": "Chat",
"columnsFrom": ["chatId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"Vote_messageId_Message_id_fk": {
"name": "Vote_messageId_Message_id_fk",
"tableFrom": "Vote",
"tableTo": "Message",
"columnsFrom": ["messageId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Vote_chatId_messageId_pk": {
"name": "Vote_chatId_messageId_pk",
"columns": ["chatId", "messageId"]
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -57,6 +57,13 @@
"when": 1757362773211, "when": 1757362773211,
"tag": "0007_flowery_ben_parker", "tag": "0007_flowery_ben_parker",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1768479010084,
"tag": "0008_flat_forgotten_one",
"breakpoints": true
} }
] ]
} }

View file

@ -6,8 +6,8 @@
"dev": "next dev --turbo", "dev": "next dev --turbo",
"build": "tsx lib/db/migrate && next build", "build": "tsx lib/db/migrate && next build",
"start": "next start", "start": "next start",
"lint": "npx ultracite@latest check", "lint": "ultracite check",
"format": "npx ultracite@latest fix", "format": "ultracite fix",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "npx tsx lib/db/migrate.ts", "db:migrate": "npx tsx lib/db/migrate.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
@ -18,9 +18,9 @@
"test": "export PLAYWRIGHT=True && pnpm exec playwright test" "test": "export PLAYWRIGHT=True && pnpm exec playwright test"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/gateway": "2.0.0-beta.85", "@ai-sdk/gateway": "^3.0.15",
"@ai-sdk/provider": "3.0.0-beta.27", "@ai-sdk/provider": "^3.0.3",
"@ai-sdk/react": "3.0.0-beta.162", "@ai-sdk/react": "3.0.39",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6", "@codemirror/lang-python": "^6.1.6",
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
@ -47,7 +47,7 @@
"@vercel/functions": "^2.0.0", "@vercel/functions": "^2.0.0",
"@vercel/otel": "^1.12.0", "@vercel/otel": "^1.12.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "6.0.0-beta.159", "ai": "6.0.37",
"bcrypt-ts": "^5.0.2", "bcrypt-ts": "^5.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@ -87,11 +87,11 @@
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
"redis": "^5.0.0", "redis": "^5.0.0",
"resumable-stream": "^2.0.0", "resumable-stream": "^2.2.10",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shiki": "^3.14.0", "shiki": "^3.21.0",
"sonner": "^1.5.0", "sonner": "^1.5.0",
"streamdown": "^1.4.0", "streamdown": "^2.0.1",
"swr": "^2.2.5", "swr": "^2.2.5",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@ -100,7 +100,7 @@
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.2", "@biomejs/biome": "2.3.11",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4.1.13", "@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
@ -116,7 +116,7 @@
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"tsx": "^4.19.1", "tsx": "^4.19.1",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"ultracite": "5.3.9" "ultracite": "^7.0.11"
}, },
"packageManager": "pnpm@9.12.3" "packageManager": "pnpm@9.12.3"
} }

2353
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -9,12 +9,18 @@ test.describe("Model Selector", () => {
test("displays a model button", async ({ page }) => { test("displays a model button", async ({ page }) => {
// Look for any button with model-related content // Look for any button with model-related content
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); const modelButton = page
.locator("button")
.filter({ hasText: MODEL_BUTTON_REGEX })
.first();
await expect(modelButton).toBeVisible(); await expect(modelButton).toBeVisible();
}); });
test("opens model selector popover on click", async ({ page }) => { test("opens model selector popover on click", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); const modelButton = page
.locator("button")
.filter({ hasText: MODEL_BUTTON_REGEX })
.first();
await modelButton.click(); await modelButton.click();
// Search input should be visible in the popover // Search input should be visible in the popover
@ -22,7 +28,10 @@ test.describe("Model Selector", () => {
}); });
test("can search for models", async ({ page }) => { test("can search for models", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); const modelButton = page
.locator("button")
.filter({ hasText: MODEL_BUTTON_REGEX })
.first();
await modelButton.click(); await modelButton.click();
const searchInput = page.getByPlaceholder("Search models..."); const searchInput = page.getByPlaceholder("Search models...");
@ -33,7 +42,10 @@ test.describe("Model Selector", () => {
}); });
test("can close model selector by clicking outside", async ({ page }) => { test("can close model selector by clicking outside", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); const modelButton = page
.locator("button")
.filter({ hasText: MODEL_BUTTON_REGEX })
.first();
await modelButton.click(); await modelButton.click();
await expect(page.getByPlaceholder("Search models...")).toBeVisible(); await expect(page.getByPlaceholder("Search models...")).toBeVisible();
@ -45,7 +57,10 @@ test.describe("Model Selector", () => {
}); });
test("shows model provider groups", async ({ page }) => { test("shows model provider groups", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); const modelButton = page
.locator("button")
.filter({ hasText: MODEL_BUTTON_REGEX })
.first();
await modelButton.click(); await modelButton.click();
// Should show provider group headers // Should show provider group headers
@ -54,7 +69,10 @@ test.describe("Model Selector", () => {
}); });
test("can select a different model", async ({ page }) => { test("can select a different model", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); const modelButton = page
.locator("button")
.filter({ hasText: MODEL_BUTTON_REGEX })
.first();
await modelButton.click(); await modelButton.click();
// Select a specific model // Select a specific model
@ -64,6 +82,8 @@ test.describe("Model Selector", () => {
await expect(page.getByPlaceholder("Search models...")).not.toBeVisible(); await expect(page.getByPlaceholder("Search models...")).not.toBeVisible();
// Model button should now show the selected model // Model button should now show the selected model
await expect(page.locator("button").filter({ hasText: "Claude Haiku" }).first()).toBeVisible(); await expect(
page.locator("button").filter({ hasText: "Claude Haiku" }).first()
).toBeVisible();
}); });
}); });

View file

@ -23,7 +23,11 @@ export function getResponseChunksByPrompt(
{ type: "text-start", id: "t1" }, { type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Hello, world!" }, { type: "text-delta", id: "t1", delta: "Hello, world!" },
{ type: "text-end", id: "t1" }, { type: "text-end", id: "t1" },
{ type: "finish", finishReason: "stop", usage: mockUsage } {
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: mockUsage,
}
); );
return chunks; return chunks;