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
2 changes: 1 addition & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ CONVEX_DEPLOYMENT=
NEXT_PUBLIC_CONVEX_URL=
# Convex Service Role Key (Required for securing public functions)
# Generate a secure random string and add it to your Convex environment variables
# CONVEX_SERVICE_ROLE_KEY=
CONVEX_SERVICE_ROLE_KEY="replace-with-a-32+char-random-secret"


# WorkOS Authentication (Required for user management and conversation persistence)
Expand Down
49 changes: 23 additions & 26 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,13 @@ export async function POST(req: NextRequest) {
const { userId, subscription } = await getUserIDAndPro(req);
const userLocation = geolocation(req);

// Check if free user is trying to use agent mode
if (mode === "agent" && subscription === "free") {
throw new ChatSDKError(
"forbidden:chat",
"Agent mode is only available for Pro users. Please upgrade to access this feature.",
);
}

// Get existing messages, merge with new messages, and truncate
const { truncatedMessages, chat, isNewChat } = await getMessagesByChatId({
chatId,
userId,
Expand All @@ -112,41 +110,38 @@ export async function POST(req: NextRequest) {
});
}

// Check rate limit for the user with mode
await checkRateLimit(userId, mode, subscription);

// Process chat messages with moderation
const { executionMode, processedMessages, selectedModel } =
await processChatMessages({
messages: truncatedMessages,
mode,
subscription,
});

// Get user customization to check memory preference (outside stream to avoid duplicate calls)
const userCustomization = await getUserCustomization({ userId });
const memoryEnabled = userCustomization?.include_memory_entries ?? true;
const posthog = PostHogClient();
const assistantMessageId = uuidv4();

// Clear any previous active stream id before starting a new one (non-temporary chats)
if (!temporary) {
await setActiveStreamId({ chatId, activeStreamId: undefined });
}

const stream = createUIMessageStream({
execute: async ({ writer }) => {
const { tools, getSandbox, getTodoManager } = createTools(
userId,
writer,
mode,
executionMode,
userLocation,
baseTodos,
memoryEnabled,
temporary,
assistantMessageId,
);
const { tools, getSandbox, getTodoManager, getFileAccumulator } =
createTools(
userId,
writer,
mode,
executionMode,
userLocation,
baseTodos,
memoryEnabled,
temporary,
assistantMessageId,
);

// Generate title in parallel only for non-temporary new chats
const titlePromise =
Expand Down Expand Up @@ -180,15 +175,14 @@ export async function POST(req: NextRequest) {
reasoningEffort: "medium",
}),
},
...(subscription === "free"
? {
openrouter: {
provider: {
sort: "price",
},
},
}
: {}),
openrouter: {
...(subscription === "free" && {
provider: {
sort: "price",
},
}),
parallelToolCalls: false,
},
},
headers: getAIHeaders(),
experimental_transform: smoothStream({ chunking: "word" }),
Expand Down Expand Up @@ -253,11 +247,14 @@ export async function POST(req: NextRequest) {
generateMessageId: () => assistantMessageId,
onFinish: async ({ messages }) => {
if (temporary) return;
const newFileIds = getFileAccumulator().getAll();
for (const message of messages) {
await saveMessage({
chatId,
userId,
message,
extraFileIds:
message.role === "assistant" ? newFileIds : undefined,
});
}
},
Expand Down
54 changes: 43 additions & 11 deletions app/components/FilePartRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Image from "next/image";
import React, { useState, memo, useMemo } from "react";
import { ImageViewer } from "./ImageViewer";
import { AlertCircle, File } from "lucide-react";
import { AlertCircle, File, Eye } from "lucide-react";
import { FilePart, FilePartRendererProps } from "@/types/file";

const FilePartRendererComponent = ({
Expand All @@ -22,29 +22,58 @@ const FilePartRendererComponent = ({
icon,
fileName,
subtitle,
url,
}: {
partId: string;
icon: React.ReactNode;
fileName: string;
subtitle: string;
}) => (
<div
key={partId}
className="p-2 w-full max-w-80 min-w-64 border rounded-lg bg-background"
>
url?: string;
}) => {
const content = (
<div className="flex flex-row items-center gap-2">
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[#FF5588] flex items-center justify-center">
{icon}
</div>
<div className="overflow-hidden">
<div className="truncate font-semibold text-sm">{fileName}</div>
<div className="text-muted-foreground truncate text-xs">
<div className="overflow-hidden flex-1">
<div className="truncate font-semibold text-sm text-left">
{fileName}
</div>
<div className="text-muted-foreground truncate text-xs text-left">
{subtitle}
</div>
</div>
{url && (
<div className="flex items-center justify-center w-6 h-6 rounded-md border border-border opacity-0 group-hover:opacity-100 transition-opacity">
<Eye className="w-4 h-4 text-muted-foreground" />
</div>
)}
</div>
</div>
);
);

if (url) {
return (
<button
key={partId}
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
className="group p-2 w-full max-w-80 min-w-64 border rounded-lg bg-background hover:bg-secondary transition-colors cursor-pointer"
type="button"
aria-label={`Open ${fileName}`}
>
{content}
</button>
);
}

return (
<div
key={partId}
className="p-2 w-full max-w-80 min-w-64 border rounded-lg bg-background"
>
{content}
</div>
);
};
PreviewCard.displayName = "FilePreviewCard";
return PreviewCard;
}, []);
Expand All @@ -63,6 +92,7 @@ const FilePartRendererComponent = ({
icon={<AlertCircle className="h-6 w-6 text-red-500" />}
fileName={part.name || part.filename || "Unknown file"}
subtitle="File URL not available"
url={undefined}
/>
);
}
Expand Down Expand Up @@ -121,6 +151,7 @@ const FilePartRendererComponent = ({
icon={<File className="h-6 w-6 text-white" />}
fileName={part.name || part.filename || "Document"}
subtitle="Document"
url={actualUrl}
/>
);
},
Expand All @@ -144,6 +175,7 @@ const FilePartRendererComponent = ({
icon={<File className="h-6 w-6 text-white" />}
fileName={part.name || part.filename || "Unknown file"}
subtitle="Document"
url={part.url}
/>
);
}, [messageId, partIndex, part.url, part.fileId]);
Expand Down
33 changes: 33 additions & 0 deletions app/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
hasTextContent,
findLastAssistantMessageIndex,
extractWebSourcesFromMessage,
extractSavedFilesFromMessage,
} from "@/lib/utils/message-utils";
import type { ChatStatus, ChatMessage } from "@/types";
import { toast } from "sonner";
Expand Down Expand Up @@ -138,6 +139,12 @@ export const Messages = ({
[],
);

// Extract saved files (memoized adapter)
const extractSavedFiles = useCallback(
(message: ChatMessage) => extractSavedFilesFromMessage(message as any),
[],
);

// Handle scroll to load more messages when scrolling to top
const handleScroll = useCallback(() => {
if (!scrollRef.current || !loadMore || paginationStatus !== "CanLoadMore") {
Expand Down Expand Up @@ -208,6 +215,12 @@ export const Messages = ({
status === "streaming" &&
!messageHasTextContent;

// Get saved files for assistant messages
const savedFiles =
!isUser && (isLastAssistantMessage ? status !== "streaming" : true)
? extractSavedFiles(message)
: [];

return (
<div
key={message.id}
Expand Down Expand Up @@ -302,6 +315,26 @@ export const Messages = ({
</div>
)}

{/* Saved files from tools (shown after message content for assistant) */}
{!isUser && savedFiles.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2 w-full">
{savedFiles.map((file, fileIndex) => (
<FilePartRenderer
key={`${message.id}-saved-file-${fileIndex}`}
part={{
url: file.downloadUrl,
name: file.filename,
filename: file.path,
mediaType: undefined,
}}
partIndex={fileIndex}
messageId={message.id}
totalFileParts={savedFiles.length}
/>
))}
</div>
)}

{/* Loading state */}
{shouldShowLoader && (
<div className="mt-1 flex justify-start">
Expand Down
8 changes: 3 additions & 5 deletions app/hooks/useChatHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,11 @@ export const useChatHandlers = ({
}, [temporaryChatsEnabled]);

const deleteLastAssistantMessage = useMutation(
api.messages.deleteLastAssistantMessageFromClient,
);
const saveAssistantMessage = useMutation(
api.messages.saveAssistantMessageFromClient,
api.messages.deleteLastAssistantMessage,
);
const saveAssistantMessage = useMutation(api.messages.saveAssistantMessage);
const regenerateWithNewContent = useMutation(
api.messages.regenerateWithNewContentFromClient,
api.messages.regenerateWithNewContent,
);

const handleSubmit = async (e: React.FormEvent) => {
Expand Down
8 changes: 7 additions & 1 deletion app/hooks/useFileUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export const useFileUpload = () => {
const deleteFile = useMutation(api.fileStorage.deleteFile);
const saveFile = useAction(api.fileActions.saveFile);

// Wrap Convex mutation to match `() => Promise<string>` signature expected by the util
const generateUploadUrlFn = useCallback(
() => generateUploadUrl({}),
[generateUploadUrl],
);

// Helper function to check and validate files before processing
const validateAndFilterFiles = useCallback(
(files: File[]): FileProcessingResult => {
Expand Down Expand Up @@ -176,7 +182,7 @@ export const useFileUpload = () => {
try {
const { fileId, url, tokens } = await uploadSingleFileToConvex(
file,
generateUploadUrl,
generateUploadUrlFn,
saveFile,
);

Expand Down
20 changes: 13 additions & 7 deletions components/ai-elements/reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import { createContext, memo, useContext, useEffect } from "react";
import { createContext, memo, useContext, useEffect, useRef } from "react";
import type { ComponentProps } from "react";

type ReasoningContextValue = {
Expand Down Expand Up @@ -62,10 +62,7 @@ export const Reasoning = memo(
<ReasoningContext.Provider
value={{ isOpen: !!isOpen, setIsOpen, isStreaming }}
>
<div
className={cn("not-prose max-w-prose space-y-2", className)}
{...props}
>
<div className={cn("not-prose w-full space-y-2", className)} {...props}>
{children}
</div>
</ReasoningContext.Provider>
Expand Down Expand Up @@ -117,12 +114,21 @@ export type ReasoningContentProps = ComponentProps<typeof CollapsibleContent>;

export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => {
const { isOpen } = useReasoningContext();
const { isOpen, isStreaming } = useReasoningContext();
const contentRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (isStreaming && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [children, isStreaming]);

return (
<Collapsible open={isOpen}>
<CollapsibleContent
ref={contentRef}
className={cn(
"mt-2 space-y-3",
"mt-2 space-y-3 opacity-50 max-h-60 overflow-y-auto",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
Expand Down
Loading