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.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ OPENAI_API_KEY=your_openai_api_key_here

# AI Model Configuration
# NEXT_PUBLIC_AGENT_MODEL=
# NEXT_PUBLIC_VISION_MODEL
# NEXT_PUBLIC_TITLE_MODEL=

# Rate Limiting (Upstash Redis)
Expand Down
32 changes: 20 additions & 12 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
saveMessage,
updateChat,
} from "@/lib/db/actions";
import { truncateMessagesWithFileTokens } from "@/lib/utils/file-token-utils";
import { v4 as uuidv4 } from "uuid";
import { processChatMessages } from "@/lib/chat/chat-processor";
import { myProvider } from "@/lib/ai/providers";
Expand Down Expand Up @@ -58,22 +59,26 @@ export async function POST(req: NextRequest) {
// Check rate limit for the user
await checkRateLimit(userId, isPro);

// Handle initial chat setup, regeneration, and save user message
// Truncate messages to stay within token limit with file tokens included
const truncatedMessages = await truncateMessagesWithFileTokens(messages);

// Handle initial chat setup, regeneration, and save user message with truncated messages
const { isNewChat } = await handleInitialChatAndUserMessage({
chatId,
userId,
messages,
messages: truncatedMessages,
regenerate,
});

// Process chat messages with moderation, truncation, and analytics
// Process chat messages with moderation and analytics
const posthog = PostHogClient();
const { executionMode, truncatedMessages } = await processChatMessages({
messages,
mode,
userID: userId,
posthog,
});
const { executionMode, processedMessages, hasMediaFiles } =
await processChatMessages({
messages: truncatedMessages,
mode,
userID: userId,
posthog,
});

const stream = createUIMessageStream({
execute: async ({ writer }) => {
Expand All @@ -89,16 +94,19 @@ export async function POST(req: NextRequest) {
// Generate title in parallel if this is a new chat
const titlePromise = isNewChat
? generateTitleFromUserMessageWithWriter(
truncatedMessages,
processedMessages,
controller.signal,
writer,
)
: Promise.resolve(undefined);

// Select the appropriate model based on whether media files are present
const selectedModel = hasMediaFiles ? "vision-model" : "agent-model";

const result = streamText({
model: myProvider.languageModel("agent-model"),
model: myProvider.languageModel(selectedModel),
system: systemPrompt(mode, executionMode),
messages: convertToModelMessages(truncatedMessages),
messages: convertToModelMessages(processedMessages),
tools,
abortSignal: controller.signal,
headers: getAIHeaders(),
Expand Down
98 changes: 98 additions & 0 deletions app/components/AttachmentButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Paperclip } from "lucide-react";
import { useGlobalState } from "../contexts/GlobalState";
import { useUpgrade } from "../hooks/useUpgrade";
import { useState } from "react";

interface AttachmentButtonProps {
onAttachClick: () => void;
disabled?: boolean;
}

export const AttachmentButton = ({
onAttachClick,
disabled = false,
}: AttachmentButtonProps) => {
const { hasProPlan, isCheckingProPlan } = useGlobalState();
const { handleUpgrade, upgradeLoading } = useUpgrade();
const [popoverOpen, setPopoverOpen] = useState(false);

const handleClick = () => {
if (hasProPlan) {
onAttachClick();
} else {
setPopoverOpen(true);
}
};

const handleUpgradeClick = async () => {
await handleUpgrade();
// Don't close popover here - let it stay open to show loading state
// The popover will naturally close when user navigates to Stripe
};

// If user has pro plan or we're checking, show normal tooltip behavior
if (hasProPlan || isCheckingProPlan) {
return (
<TooltipPrimitive.Root>
<TooltipTrigger asChild>
<Button
type="button"
onClick={onAttachClick}
variant="ghost"
size="sm"
className="rounded-full p-0 w-8 h-8 min-w-0"
aria-label="Attach files"
disabled={disabled || isCheckingProPlan}
>
<Paperclip className="w-[15px] h-[15px]" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Add files</p>
</TooltipContent>
</TooltipPrimitive.Root>
);
}

// If user doesn't have pro plan, show popover with upgrade option
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
onClick={handleClick}
variant="ghost"
size="sm"
className="rounded-full p-0 w-8 h-8 min-w-0"
aria-label="Attach files"
disabled={disabled}
>
<Paperclip className="w-[15px] h-[15px]" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-4" side="top" align="start">
<div className="space-y-3">
<h3 className="font-semibold text-base">Upgrade to Pro</h3>
<p className="text-sm text-muted-foreground">
Get access to file attachments and more features with Pro
</p>
<Button
onClick={handleUpgradeClick}
disabled={upgradeLoading}
className="w-full"
>
{upgradeLoading ? "Loading..." : "Upgrade now"}
</Button>
</div>
</PopoverContent>
</Popover>
);
};
114 changes: 101 additions & 13 deletions app/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,51 @@ import TextareaAutosize from "react-textarea-autosize";
import { useGlobalState } from "../contexts/GlobalState";
import { TodoPanel } from "./TodoPanel";
import type { ChatStatus } from "@/types";
import { FileUploadPreview } from "./FileUploadPreview";
import { ScrollToBottomButton } from "./ScrollToBottomButton";
import { AttachmentButton } from "./AttachmentButton";
import { useFileUpload } from "../hooks/useFileUpload";
import { useEffect, useRef } from "react";

interface ChatInputProps {
onSubmit: (e: React.FormEvent) => void;
onStop: () => void;
status: ChatStatus;
isCentered?: boolean;
hasMessages?: boolean;
isAtBottom?: boolean;
onScrollToBottom?: () => void;
}

export const ChatInput = ({
onSubmit,
onStop,
status,
isCentered = false,
hasMessages = false,
isAtBottom = true,
onScrollToBottom,
}: ChatInputProps) => {
const { input, setInput, mode, setMode } = useGlobalState();
const { input, setInput, mode, setMode, uploadedFiles, isUploadingFiles } =
useGlobalState();
const {
fileInputRef,
handleFileUploadEvent,
handleRemoveFile,
handleAttachClick,
handlePasteEvent,
} = useFileUpload();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isGenerating = status === "submitted" || status === "streaming";

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (status === "ready" && input.trim()) {
// Allow submission if there's text input or files attached, and no files are uploading
if (
status === "ready" &&
!isUploadingFiles &&
(input.trim() || uploadedFiles.length > 0)
) {
onSubmit(e);
}
};
Expand All @@ -60,15 +85,53 @@ export const ChatInput = ({
[isGenerating, onStop],
);

// Handle paste events for file uploads
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
// Only handle paste if the textarea is focused
if (textareaRef.current === document.activeElement) {
const filesProcessed = await handlePasteEvent(e);
// If files were processed, the event.preventDefault() is already called
// in handlePasteEvent, so no additional action needed here
}
};

document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("paste", handlePaste);
};
}, [handlePasteEvent]);

return (
<div className={`relative px-4 ${isCentered ? "" : "pb-3"}`}>
<div className="mx-auto w-full max-w-full sm:max-w-[768px] sm:min-w-[390px] flex flex-col flex-1">
{/* Todo Panel */}
<TodoPanel status={status} />

<div className="flex flex-col gap-3 rounded-[22px] transition-all relative bg-input-chat py-3 max-h-[300px] shadow-[0px_12px_32px_0px_rgba(0,0,0,0.02)] border border-black/8 dark:border-border">
{/* File Upload Preview */}
{uploadedFiles && uploadedFiles.length > 0 && (
<FileUploadPreview
uploadedFiles={uploadedFiles}
onRemoveFile={handleRemoveFile}
/>
)}

{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
accept="*"
multiple
className="hidden"
onChange={handleFileUploadEvent}
/>

<div
className={`flex flex-col gap-3 transition-all relative bg-input-chat py-3 max-h-[300px] shadow-[0px_12px_32px_0px_rgba(0,0,0,0.02)] border border-black/8 dark:border-border ${uploadedFiles && uploadedFiles.length > 0 ? "rounded-b-[22px] border-t-0" : "rounded-[22px]"}`}
>
<div className="overflow-y-auto pl-4 pr-2">
<TextareaAutosize
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
Expand All @@ -88,6 +151,12 @@ export const ChatInput = ({
/>
</div>
<div className="px-3 flex gap-2 items-center">
{/* Attachment Button */}
<AttachmentButton
onAttachClick={handleAttachClick}
disabled={isGenerating}
/>

{/* Mode selector */}
<div className="flex items-center gap-2">
<DropdownMenu>
Expand Down Expand Up @@ -163,25 +232,44 @@ export const ChatInput = ({
<form onSubmit={handleSubmit}>
<TooltipPrimitive.Root>
<TooltipTrigger asChild>
<Button
type="submit"
disabled={status !== "ready" || !input.trim()}
variant="default"
className="rounded-full p-0 w-8 h-8 min-w-0"
aria-label="Send message"
>
<ArrowUp className="w-[15px] h-[15px]" />
</Button>
<div className="inline-block">
<Button
type="submit"
disabled={
status !== "ready" ||
isUploadingFiles ||
(!input.trim() && uploadedFiles.length === 0)
}
variant="default"
className="rounded-full p-0 w-8 h-8 min-w-0"
aria-label="Send message"
>
<ArrowUp className="w-[15px] h-[15px]" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Send (⏎)</p>
<p>
{isUploadingFiles ? "File upload pending" : "Send (⏎)"}
</p>
</TooltipContent>
</TooltipPrimitive.Root>
</form>
)}
</div>
</div>
</div>

{/* ScrollToBottomButton positioned relative to input */}
{onScrollToBottom && (
<div className="absolute -top-16 left-1/2 -translate-x-1/2 z-40">
<ScrollToBottomButton
onClick={onScrollToBottom}
hasMessages={hasMessages}
isAtBottom={isAtBottom}
/>
</div>
)}
</div>
</div>
);
Expand Down
43 changes: 43 additions & 0 deletions app/components/DragDropOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { Upload } from "lucide-react";

interface DragDropOverlayProps {
isVisible: boolean;
isDragOver: boolean;
}

export const DragDropOverlay = ({
isVisible,
isDragOver,
}: DragDropOverlayProps) => {
if (!isVisible) return null;

return (
<div
className={`absolute inset-0 z-50 flex items-center justify-center transition-colors duration-200 ${
isDragOver
? "bg-accent/30 backdrop-blur-sm"
: "bg-muted/20 backdrop-blur-sm"
}`}
>
<div
className={`flex flex-col items-center justify-center p-8 rounded-2xl border-2 border-dashed transition-all duration-200 ${
isDragOver
? "border-primary bg-card/95 text-foreground scale-105 shadow-lg"
: "border-border bg-card/90 text-muted-foreground"
}`}
>
<Upload
className={`w-12 h-12 mb-4 transition-all duration-200 ${
isDragOver ? "text-foreground scale-110" : "text-muted-foreground"
}`}
/>
<h3 className="text-xl font-semibold mb-2">Add anything</h3>
<p className="text-sm opacity-80">
Drop files here to add them to the conversation
</p>
</div>
</div>
);
};
Loading