diff --git a/.playwright-mcp/app-homepage.png b/.playwright-mcp/app-homepage.png new file mode 100644 index 0000000..38c38dc Binary files /dev/null and b/.playwright-mcp/app-homepage.png differ diff --git a/.playwright-mcp/chatbot-conversation.png b/.playwright-mcp/chatbot-conversation.png new file mode 100644 index 0000000..9b0f1f1 Binary files /dev/null and b/.playwright-mcp/chatbot-conversation.png differ diff --git a/.playwright-mcp/page-2025-09-04T20-48-56-226Z.png b/.playwright-mcp/page-2025-09-04T20-48-56-226Z.png new file mode 100644 index 0000000..d5510bf Binary files /dev/null and b/.playwright-mcp/page-2025-09-04T20-48-56-226Z.png differ diff --git a/.playwright-mcp/successful-login.png b/.playwright-mcp/successful-login.png new file mode 100644 index 0000000..3b8f1e9 Binary files /dev/null and b/.playwright-mcp/successful-login.png differ diff --git a/CONSOLIDATION_PLAN.md b/CONSOLIDATION_PLAN.md new file mode 100644 index 0000000..462920f --- /dev/null +++ b/CONSOLIDATION_PLAN.md @@ -0,0 +1,65 @@ +# File Structure Consolidation Plan + +## Current Issues +- Duplicate authentication logic across multiple files +- Scattered database operations +- Inconsistent file organization + +## Recommended Structure + +### 1. Authentication Consolidation +**Current:** +- `db/auth.ts` - Auth functions +- `lib/auth.ts` - getUser function +- `lib/auth/session.ts` - Session utilities +- `lib/auth/wallet.ts` - Wallet utilities + +**Recommended:** +- `lib/auth/index.ts` - Main auth exports +- `lib/auth/client.ts` - Client-side auth functions +- `lib/auth/server.ts` - Server-side auth functions +- `lib/auth/session.ts` - Session management +- `lib/auth/wallet.ts` - Wallet integration + +### 2. Database Operations Consolidation +**Current:** +- `db/queries.ts` - All queries +- `db/mutations.ts` - All mutations +- `db/cached-queries.ts` - Cached queries +- `db/storage.ts` - Storage operations + +**Recommended:** +- `lib/database/index.ts` - Main database exports +- `lib/database/queries.ts` - All database queries +- `lib/database/mutations.ts` - All database mutations +- `lib/database/cache.ts` - Caching logic +- `lib/database/storage.ts` - Storage operations + +### 3. Supabase Configuration (Keep As-Is) +**Current (CORRECT):** +- `supabase/` - Migrations and config +- `lib/supabase/` - Client configurations + +## Migration Steps + +1. **Create new structure:** + ```bash + mkdir -p lib/database + mkdir -p lib/auth + ``` + +2. **Move and consolidate files:** + - Move `db/*` to `lib/database/` + - Consolidate auth files into `lib/auth/` + - Update all imports + +3. **Update imports across the codebase** + +4. **Remove old `db/` folder** + +## Benefits +- Clear separation of concerns +- Consistent file organization +- Easier maintenance +- Better TypeScript support +- Follows Next.js best practices diff --git a/VERCEL_ENV_SETUP.md b/VERCEL_ENV_SETUP.md new file mode 100644 index 0000000..4d88224 --- /dev/null +++ b/VERCEL_ENV_SETUP.md @@ -0,0 +1,55 @@ +# Vercel Environment Variables Setup + +This document lists all the environment variables that need to be configured in Vercel for the application to work properly. + +## Required Environment Variables + +### Supabase Configuration +``` +NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key +``` + +### OpenAI Configuration +``` +OPENAI_API_KEY=your_openai_api_key +``` + +### Application Configuration +``` +NEXT_PUBLIC_SITE_URL=https://your-domain.vercel.app +NEXT_PUBLIC_ENVIRONMENT=production +NODE_ENV=production +``` + +### Optional Features +``` +NEXT_PUBLIC_FEATURE_ATTACHMENTS=true +NEXT_PUBLIC_FEATURE_WEB_SEARCH=true +``` + +### Optional Services +``` +BING_API_KEY=your_bing_search_api_key +BLOB_READ_WRITE_TOKEN=your_vercel_blob_token +``` + +## How to Add Environment Variables in Vercel + +1. Go to your Vercel dashboard +2. Select your project +3. Go to Settings → Environment Variables +4. Add each variable with the appropriate value +5. Make sure to add them for all environments (Production, Preview, Development) + +## Security Notes + +- Never commit actual API keys to the repository +- Use Vercel's environment variables for all sensitive data +- The `SUPABASE_SERVICE_ROLE_KEY` should be kept secret +- The `OPENAI_API_KEY` should be kept secret + +## Local Development + +For local development, create a `.env.local` file with the same variables but use local Supabase URLs and keys. diff --git a/__tests__/chat-route.test.ts b/__tests__/chat-route.test.ts index 4ef9368..80bb3da 100644 --- a/__tests__/chat-route.test.ts +++ b/__tests__/chat-route.test.ts @@ -13,11 +13,11 @@ vi.mock("@/lib/supabase/server", () => ({ }), })); -vi.mock("@/db/cached-queries", () => ({ +vi.mock("@/lib/database/cached-queries", () => ({ getChatById: vi.fn(), })); -vi.mock("@/db/mutations", () => ({ +vi.mock("@/lib/database/mutations", () => ({ deleteChatById: vi.fn(), })); @@ -32,7 +32,7 @@ describe("Chat API Routes", () => { it("should handle successful deletion", async () => { const request = new Request("http://localhost:3000/api/chat?id=test-id"); - const { getChatById } = await import("@/db/cached-queries"); + const { getChatById } = await import("@/lib/database/cached-queries"); vi.mocked(getChatById).mockResolvedValueOnce({ user_id: "test-user-id", }); @@ -44,7 +44,7 @@ describe("Chat API Routes", () => { it("should handle unauthorized deletion", async () => { const request = new Request("http://localhost:3000/api/chat?id=test-id"); - const { getChatById } = await import("@/db/cached-queries"); + const { getChatById } = await import("@/lib/database/cached-queries"); vi.mocked(getChatById).mockResolvedValueOnce({ user_id: "different-user-id", }); diff --git a/ai-bot-vercel.code-workspace b/ai-bot-vercel.code-workspace index 5709732..ef9f5d2 100644 --- a/ai-bot-vercel.code-workspace +++ b/ai-bot-vercel.code-workspace @@ -1,8 +1,7 @@ { - "folders": [ - { - "path": "." - } - ], - "settings": {} -} + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts index df122f1..008e891 100644 --- a/app/(auth)/actions.ts +++ b/app/(auth)/actions.ts @@ -2,7 +2,7 @@ import { z } from "zod"; -import { getUser } from "@/db/cached-queries"; +import { getUser } from "@/lib/database/cached-queries"; import { createClient } from "@/lib/supabase/server"; const authFormSchema = z.object({ diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 766a1e4..f182cb1 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -9,7 +9,7 @@ import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { signIn, signInWithGitHub } from "@/db/auth"; +import { signIn, signInWithGitHub } from "@/lib/auth/client"; import { errorHandler } from "@/lib/error-handling"; export default function LoginPage() { diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 9e1f896..d9ba06f 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -8,7 +8,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { signUp, signInWithGitHub } from "@/db/auth"; +import { signUp, signInWithGitHub } from "@/lib/auth/client"; export default function RegisterPage() { const [isLoading, setIsLoading] = useState(false); diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 9d6311b..26435cb 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -11,14 +11,14 @@ import { z } from "zod"; import { customModel } from "@/ai"; import { models } from "@/ai/models"; import { blocksPrompt, regularPrompt, systemPrompt } from "@/ai/prompts"; -import { getChatById, getDocumentById, getSession } from "@/db/cached-queries"; +import { getChatById, getDocumentById, getSession } from "@/lib/database/cached-queries"; import { saveChat, saveDocument, saveMessages, saveSuggestions, deleteChatById, -} from "@/db/mutations"; +} from "@/lib/database/mutations"; import { createClient } from "@/lib/supabase/server"; import { MessageRole } from "@/lib/supabase/types"; import { diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts index c630753..b22aa8f 100644 --- a/app/(chat)/api/document/route.ts +++ b/app/(chat)/api/document/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { saveDocument } from "@/db/mutations"; +import { saveDocument } from "@/lib/database/mutations"; import { createClient } from "@/lib/supabase/server"; export async function GET(req: Request) { diff --git a/app/(chat)/api/files/save/route.ts b/app/(chat)/api/files/save/route.ts index 9efd95c..c92849f 100644 --- a/app/(chat)/api/files/save/route.ts +++ b/app/(chat)/api/files/save/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { saveDocument } from "@/db/mutations"; +import { saveDocument } from "@/lib/database/mutations"; import { createClient } from "@/lib/supabase/server"; export async function POST(req: Request) { diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts index ad0a543..3e1f392 100644 --- a/app/(chat)/api/history/route.ts +++ b/app/(chat)/api/history/route.ts @@ -1,4 +1,4 @@ -import { getSession } from "@/db/cached-queries"; +import { getSession } from "@/lib/database/cached-queries"; import { createClient } from "@/lib/supabase/server"; export async function GET() { diff --git a/app/(chat)/api/suggestions/route.ts b/app/(chat)/api/suggestions/route.ts index 0bb289a..1e1d8a2 100644 --- a/app/(chat)/api/suggestions/route.ts +++ b/app/(chat)/api/suggestions/route.ts @@ -1,4 +1,4 @@ -import { getSession, getSuggestionsByDocumentId } from "@/db/cached-queries"; +import { getSession, getSuggestionsByDocumentId } from "@/lib/database/cached-queries"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts index ec0efa1..27a7d52 100644 --- a/app/(chat)/api/vote/route.ts +++ b/app/(chat)/api/vote/route.ts @@ -1,5 +1,5 @@ -import { getSession } from "@/db/cached-queries"; -import { voteMessage } from "@/db/mutations"; +import { getSession } from "@/lib/database/cached-queries"; +import { voteMessage } from "@/lib/database/mutations"; import { createClient } from "@/lib/supabase/server"; export async function POST(request: Request) { try { diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 134adce..89c860d 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -7,7 +7,7 @@ import { getChatById, getMessagesByChatId, getSession, -} from "@/db/cached-queries"; +} from "@/lib/database/cached-queries"; import { convertToUIMessages } from "@/lib/utils"; export default async function Page(props: { params: Promise }) { diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index ba0a5a3..a014abf 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -2,7 +2,7 @@ import { cookies } from "next/headers"; import { AppSidebar } from "@/components/custom/app-sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { getSession } from "@/db/cached-queries"; +import { getSession } from "@/lib/database/cached-queries"; export default async function Layout({ children, diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 87edaef..f313f91 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -1,7 +1,7 @@ import { cookies } from "next/headers"; import { DEFAULT_MODEL_NAME, models } from "@/ai/models"; -import { Chat } from "@/components/custom/chat"; +import { ModernChat } from "@/components/custom/modern-chat"; import { generateUUID } from "@/lib/utils"; export default async function Page() { @@ -15,7 +15,7 @@ export default async function Page() { DEFAULT_MODEL_NAME; return ( - { - const handleAuthCallback = async () => { - const supabase = createClient(); - - // Handle OAuth code exchange (for OAuth providers) - const code = searchParams.get("code"); - if (code) { - const { error } = await supabase.auth.exchangeCodeForSession(code); - if (error) { - console.error("OAuth callback error:", error); - router.push("/auth-error"); - return; - } - router.push("/"); - return; - } - - // Handle magic link tokens (from URL hash) - const hashParams = new URLSearchParams(window.location.hash.substring(1)); - const accessToken = hashParams.get("access_token"); - const refreshToken = hashParams.get("refresh_token"); - const error = hashParams.get("error"); - const errorDescription = hashParams.get("error_description"); - - if (error) { - console.error("Magic link error:", error, errorDescription); - router.push("/auth-error"); - return; - } - - if (accessToken && refreshToken) { - // Set the session with the tokens from the magic link - const { error: sessionError } = await supabase.auth.setSession({ - access_token: accessToken, - refresh_token: refreshToken, - }); - - if (sessionError) { - console.error("Session error:", sessionError); - router.push("/auth-error"); - return; - } - - // Clear the URL hash - window.history.replaceState({}, document.title, window.location.pathname); - - // Redirect to the main application - router.push("/"); - return; - } - - // If no code or tokens, redirect to login - router.push("/login"); - }; - - handleAuthCallback(); - }, [router, searchParams]); - - return ( -
-
- -

Completing authentication...

-
-
- ); -} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..9d5becf --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,21 @@ +import { createClient } from "@/lib/supabase/server"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + const next = requestUrl.searchParams.get("next") ?? "/"; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + + if (!error) { + // Session established successfully + return NextResponse.redirect(new URL(next, request.url)); + } + } + + // If there's an error or no code, redirect to auth error page + return NextResponse.redirect(new URL("/auth-error", request.url)); +} diff --git a/components/custom/chat-history.tsx b/components/custom/chat-history.tsx index 8eb492a..456cb58 100644 --- a/components/custom/chat-history.tsx +++ b/components/custom/chat-history.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; -import { getChatsByUserId } from "@/db/cached-queries"; +import { getChatsByUserId } from "@/lib/database/cached-queries"; import { ChatHistoryClient } from "./chat-history-client"; import { ChatHistorySkeleton } from "./chat-history-skeleton"; diff --git a/components/custom/logout-button.tsx b/components/custom/logout-button.tsx index 41b041e..edf7943 100644 --- a/components/custom/logout-button.tsx +++ b/components/custom/logout-button.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { signOut } from "@/db/auth"; +import { signOut } from "@/lib/auth/client"; export function LogoutButton() { const router = useRouter(); diff --git a/components/custom/modern-chat.tsx b/components/custom/modern-chat.tsx new file mode 100644 index 0000000..dd8c570 --- /dev/null +++ b/components/custom/modern-chat.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useChat } from "ai/react"; +import type { Message, Attachment } from "ai"; +import { AnimatePresence } from "framer-motion"; +import { useState, useEffect } from "react"; +import useSWR, { useSWRConfig } from "swr"; +import { useWindowSize } from "usehooks-ts"; +import { toast } from "sonner"; + +import { Block, UIBlock } from "@/components/custom/block"; +import { BlockStreamHandler } from "@/components/custom/block-stream-handler"; +import { ChatHeader } from "@/components/custom/chat-header"; +import { ModernInput } from "@/components/custom/modern-input"; +import { ModernMessage, ModernThinkingMessage } from "@/components/custom/modern-message"; +import { Overview } from "@/components/custom/overview"; +import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { ModernIcons } from "./modern-icons"; + +import { Database } from "@/lib/supabase/types"; +import { fetcher } from "@/lib/utils"; + +type Vote = Database["public"]["Tables"]["votes"]["Row"]; + +interface FileUploadState { + progress: number; + uploading: boolean; + error: string | null; +} + +export function ModernChat({ + id, + initialMessages, + selectedModelId, +}: { + id: string; + initialMessages: Array; + selectedModelId: string; +}) { + const { mutate } = useSWRConfig(); + const { width: windowWidth = 1920, height: windowHeight = 1080 } = useWindowSize(); + + const { + messages, + setMessages, + handleSubmit, + input, + setInput, + append, + isLoading, + stop, + data: streamingData, + } = useChat({ + body: { id, modelId: selectedModelId }, + initialMessages, + onFinish: () => { + mutate("/api/history"); + }, + }); + + const [block, setBlock] = useState({ + documentId: "init", + content: "", + title: "", + status: "idle", + isVisible: false, + boundingBox: { + top: windowHeight / 4, + left: windowWidth / 4, + width: 250, + height: 50, + }, + }); + + const { data: votes } = useSWR>( + `/api/vote?chatId=${id}`, + fetcher, + ); + + const [messagesContainerRef, messagesEndRef] = useScrollToBottom(); + const [attachments, setAttachments] = useState>([]); + const [fileUpload, setFileUpload] = useState({ + progress: 0, + uploading: false, + error: null, + }); + + const handleFileUpload = async (file: File) => { + if (!file) return; + + if (file.size > 10 * 1024 * 1024) { + toast.error("File size must be less than 10MB"); + return; + } + + setFileUpload({ progress: 0, uploading: true, error: null }); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append("file", file); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + const progress = Math.round((e.loaded * 100) / e.total); + setFileUpload((prev) => ({ ...prev, progress })); + } + }); + + xhr.addEventListener("load", () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + toast.success("File uploaded successfully"); + append({ + role: "user", + content: `[File uploaded: ${file.name}](${response.url})`, + }); + resolve(response); + } else { + setFileUpload((prev) => ({ + ...prev, + error: "Upload failed", + })); + toast.error("Failed to upload file"); + reject(new Error("Upload failed")); + } + setFileUpload((prev) => ({ ...prev, uploading: false })); + }); + + xhr.addEventListener("error", () => { + setFileUpload((prev) => ({ + ...prev, + error: "Upload failed", + uploading: false, + })); + toast.error("Failed to upload file"); + reject(new Error("Upload failed")); + }); + + xhr.open("POST", "/api/upload"); + xhr.send(formData); + }); + }; + + return ( + <> +
+ + + {/* Messages Container */} +
+ {/* Welcome Message */} + {messages.length === 0 && } + + {/* Thinking Message */} + {isLoading && + messages.length > 0 && + messages[messages.length - 1].role === "user" && ( + + )} + + {/* Messages */} + + {messages.map((message, index) => ( + vote.message_id === message.id)} + /> + ))} + + + {/* Scroll Anchor */} +
+
+ + {/* Input Container */} +
+
+
{ + e.preventDefault(); + handleSubmit(e); + }} + aria-label="Chat input form" + > + + +
+
+
+ + {/* Block Overlay */} + + {block && block.isVisible && ( + + )} + + + + + {/* Keyboard Shortcuts Help */} +
+ + + + + +
+

Keyboard Shortcuts

+

⌘ / to focus input

+

⌘ K to clear chat

+

ESC to stop generation

+
+
+
+
+ + {/* File Upload Progress */} + {fileUpload.uploading && ( +
+
+ +
+
+
+
+

+ Uploading... {fileUpload.progress}% +

+
+
+
+ )} + + {/* Hidden File Input */} + { + const file = e.target.files?.[0]; + if (file) handleFileUpload(file); + }} + className="hidden" + id="file-upload" + accept="image/*,.pdf,.doc,.docx,.txt" + /> + + ); +} diff --git a/components/custom/modern-icons.tsx b/components/custom/modern-icons.tsx new file mode 100644 index 0000000..9953233 --- /dev/null +++ b/components/custom/modern-icons.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { LucideProps } from "lucide-react"; + +// Base icon props interface +interface IconProps extends LucideProps { + size?: number; + className?: string; +} + +// Modern icon wrapper with consistent styling +const Icon = ({ size = 16, className, ...props }: IconProps) => ( + +); + +// Chat & Communication Icons +export const ChatIcon = ({ size = 16, className }: IconProps) => ( + + + +); + +export const MessageIcon = ({ size = 16, className }: IconProps) => ( + + + + + +); + +export const BotIcon = ({ size = 16, className }: IconProps) => ( + + + + + + + +); + +export const UserIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +// Input & Action Icons +export const SendIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const PaperclipIcon = ({ size = 16, className }: IconProps) => ( + + + +); + +export const ImageIcon = ({ size = 16, className }: IconProps) => ( + + + + + +); + +export const FileIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const PlusIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const XIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +// Status & Feedback Icons +export const CheckIcon = ({ size = 16, className }: IconProps) => ( + + + +); + +export const CheckCheckIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const ClockIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const LoaderIcon = ({ size = 16, className }: IconProps) => ( + + + +); + +export const StopIcon = ({ size = 16, className }: IconProps) => ( + + + +); + +// Navigation & UI Icons +export const ArrowUpIcon = ({ size = 16, className }: IconProps) => ( + + + +); + +export const ArrowDownIcon = ({ size = 16, className }: IconProps) => ( + + + +); + +export const ChevronDownIcon = ({ size = 16, className }: IconProps) => ( + + + +); + +export const MoreHorizontalIcon = ({ size = 16, className }: IconProps) => ( + + + + + +); + +export const MoreVerticalIcon = ({ size = 16, className }: IconProps) => ( + + + + + +); + +// Action & Control Icons +export const CopyIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const TrashIcon = ({ size = 16, className }: IconProps) => ( + + + + + +); + +export const EditIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const ThumbUpIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const ThumbDownIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +// Specialized Icons +export const SparklesIcon = ({ size = 16, className }: IconProps) => ( + + + + + + + +); + +export const MenuIcon = ({ size = 16, className }: IconProps) => ( + + + + + +); + +export const HomeIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +export const SettingsIcon = ({ size = 16, className }: IconProps) => ( + + + + +); + +// Brand Icons (simplified versions) +export const OpenAIIcon = ({ size = 16, className }: IconProps) => ( + + + + + +); + +export const AnthropicIcon = ({ size = 16, className }: IconProps) => ( + + + + + +); + +// Typing indicator component +export const TypingIndicator = ({ size = 16, className }: IconProps) => { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ); +}; + +// Export all icons as a single object for easy importing +export const ModernIcons = { + // Chat & Communication + Chat: ChatIcon, + Message: MessageIcon, + Bot: BotIcon, + User: UserIcon, + + // Input & Actions + Send: SendIcon, + Paperclip: PaperclipIcon, + Image: ImageIcon, + File: FileIcon, + Plus: PlusIcon, + X: XIcon, + + // Status & Feedback + Check: CheckIcon, + CheckCheck: CheckCheckIcon, + Clock: ClockIcon, + Loader: LoaderIcon, + Stop: StopIcon, + + // Navigation & UI + ArrowUp: ArrowUpIcon, + ArrowDown: ArrowDownIcon, + ChevronDown: ChevronDownIcon, + MoreHorizontal: MoreHorizontalIcon, + MoreVertical: MoreVerticalIcon, + + // Actions & Controls + Copy: CopyIcon, + Trash: TrashIcon, + Edit: EditIcon, + ThumbUp: ThumbUpIcon, + ThumbDown: ThumbDownIcon, + + // Specialized + Sparkles: SparklesIcon, + Menu: MenuIcon, + Home: HomeIcon, + Settings: SettingsIcon, + + // Brand + OpenAI: OpenAIIcon, + Anthropic: AnthropicIcon, + + // Components + TypingIndicator, +}; diff --git a/components/custom/modern-input.tsx b/components/custom/modern-input.tsx new file mode 100644 index 0000000..62f4ffc --- /dev/null +++ b/components/custom/modern-input.tsx @@ -0,0 +1,641 @@ +"use client"; + +import cx from "classnames"; +import { motion, AnimatePresence } from "framer-motion"; +import { ModernIcons } from "./modern-icons"; +import React, { + useRef, + useEffect, + useState, + useCallback, + Dispatch, + SetStateAction, + ChangeEvent, + FormEvent, +} from "react"; +import { toast } from "sonner"; +import { useLocalStorage, useWindowSize } from "usehooks-ts"; + +import { createClient } from "@/lib/supabase/client"; +import { errorHandler } from "@/lib/error-handling"; + +import { PreviewAttachment } from "./preview-attachment"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; + +import type { Attachment as SupabaseAttachment } from "@/types/supabase"; +import type { + Attachment, + ChatRequestOptions, + CreateMessage, + Message, +} from "ai"; + +const suggestedActions = [ + { + title: "Help me get started", + label: "Show me what you can do", + action: "What can you help me with? Please show me your capabilities and how to get started.", + icon: "🚀", + }, + { + title: "Create a document", + label: "Start a new project", + action: "Help me create a new document for my project. I'd like to get started with a structured outline.", + icon: "📝", + }, + { + title: "Research assistance", + label: "Find information on a topic", + action: "I need help researching a topic. Can you help me find reliable information and summarize key points?", + icon: "🔍", + }, + { + title: "Code review", + label: "Review and improve code", + action: "I have some code that I'd like you to review. Can you help me identify potential improvements and best practices?", + icon: "💻", + }, +]; + +interface StagedFile { + id: string; + file: File; + previewUrl: string; + status: "staging" | "uploading" | "complete" | "error"; +} + +interface ModernInputProps { + input: string; + setInput: (value: string) => void; + isLoading: boolean; + stop: () => void; + attachments: Attachment[]; + setAttachments: Dispatch>; + messages: Message[]; + setMessages: Dispatch>; + append: ( + message: Message | CreateMessage, + chatRequestOptions?: ChatRequestOptions, + ) => Promise; + handleSubmit: ( + event?: { preventDefault?: () => void }, + chatRequestOptions?: ChatRequestOptions, + ) => void; + className?: string; + chatId: string; +} + +export function ModernInput({ + input, + setInput, + isLoading, + stop, + attachments, + setAttachments, + messages, + setMessages, + append, + handleSubmit, + className, + chatId, +}: ModernInputProps) { + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const { width } = useWindowSize(); + const supabase = createClient(); + + const [stagedFiles, setStagedFiles] = useState([]); + const [expectingText, setExpectingText] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [showSuggestions, setShowSuggestions] = useState(false); + const stagedFileNames = useRef>(new Set()); + + useEffect(() => { + if (textareaRef.current) { + adjustHeight(); + } + }, []); + + const adjustHeight = () => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`; + } + }; + + const [localStorageInput, setLocalStorageInput] = useLocalStorage( + "input", + "", + ); + + useEffect(() => { + if (textareaRef.current) { + const domValue = textareaRef.current.value; + const finalValue = domValue || localStorageInput || ""; + setInput(finalValue); + adjustHeight(); + } + }, []); + + useEffect(() => { + setLocalStorageInput(input); + }, [input, setLocalStorageInput]); + + const handleInput = (event: React.ChangeEvent) => { + setInput(event.target.value); + adjustHeight(); + }; + + const createStagedFile = useCallback((file: File): StagedFile => { + return { + id: crypto.randomUUID(), + file, + previewUrl: URL.createObjectURL(file), + status: "staging", + }; + }, []); + + const removeStagedFile = useCallback((fileId: string) => { + setStagedFiles((prev) => { + const file = prev.find((f) => f.id === fileId); + if (file) { + URL.revokeObjectURL(file.previewUrl); + } + const updatedFiles = prev.filter((f) => f.id !== fileId); + if (file) { + stagedFileNames.current.delete(file.file.name); + } + return updatedFiles; + }); + }, []); + + useEffect(() => { + return () => { + stagedFiles.forEach((file) => { + URL.revokeObjectURL(file.previewUrl); + }); + }; + }, [stagedFiles]); + + const submitForm = useCallback(async () => { + if (!input && attachments.length === 0) return; + + setExpectingText(true); + + const messageContent = { + text: input, + attachments: attachments.map((att) => ({ + url: att.url, + name: att.name, + type: att.contentType, + })), + }; + + try { + await append( + { + role: "user", + content: JSON.stringify(messageContent), + }, + { + experimental_attachments: attachments, + }, + ); + + setInput(""); + setAttachments([]); + setLocalStorageInput(""); + setShowSuggestions(false); + } catch (error) { + errorHandler.showError(error, "chat message submission"); + } finally { + setExpectingText(false); + } + }, [ + input, + attachments, + append, + setInput, + setLocalStorageInput, + setAttachments, + ]); + + const handleSuggestedAction = useCallback( + (action: string) => { + setInput(action); + submitForm(); + }, + [setInput, submitForm], + ); + + const uploadFileWithRetry = useCallback( + async (stagedFile: StagedFile, maxRetries = 3): Promise => { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const formData = new FormData(); + formData.append("file", stagedFile.file); + formData.append("chatId", chatId); + + const response = await fetch("/api/files/upload", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + setAttachments((current) => [ + ...current, + { + url: data.url, + name: stagedFile.file.name, + contentType: stagedFile.file.type, + path: data.path, + }, + ]); + + setStagedFiles((prev) => + prev.map((f) => + f.id === stagedFile.id ? { ...f, status: "complete" } : f, + ), + ); + removeStagedFile(stagedFile.id); + return true; + } catch (error) { + console.error(`Upload attempt ${attempt} failed:`, error); + + if (attempt === maxRetries) { + setStagedFiles((prev) => + prev.map((f) => (f.id === stagedFile.id ? { ...f, status: "error" } : f)), + ); + return false; + } + + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + } + } + return false; + }, + [chatId, setAttachments, removeStagedFile], + ); + + const handleFileChange = useCallback( + async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + + const newStagedFiles = files + .filter((file) => !stagedFileNames.current.has(file.name)) + .map((file) => { + stagedFileNames.current.add(file.name); + return createStagedFile(file); + }); + setStagedFiles((prev) => [...prev, ...newStagedFiles]); + + try { + const uploadPromises = newStagedFiles.map(async (stagedFile) => { + setStagedFiles((prev) => + prev.map((f) => + f.id === stagedFile.id ? { ...f, status: "uploading" } : f, + ), + ); + + return await uploadFileWithRetry(stagedFile); + }); + + const results = await Promise.all(uploadPromises); + const successCount = results.filter(Boolean).length; + const failureCount = results.length - successCount; + + if (successCount > 0) { + toast.success(`${successCount} file(s) uploaded successfully`); + } + if (failureCount > 0) { + toast.error(`${failureCount} file(s) failed to upload. You can try uploading them again.`); + } + } catch (error) { + errorHandler.showError(error, "file upload"); + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }, + [createStagedFile, uploadFileWithRetry], + ); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + } + }, [messages.length]); + + useEffect(() => { + const timer = setTimeout(() => { + textareaRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + }, []); + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const clipboardData = e.clipboardData; + if (!clipboardData) return; + + const items = Array.from(clipboardData.items); + const imageItems = items.filter( + (item) => item.kind === "file" && item.type.startsWith("image/"), + ); + + if (imageItems.length > 0) { + e.preventDefault(); + + const files = imageItems + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) + .map( + (file) => + new File( + [file], + `screenshot-${Date.now()}.${file.type.split("/")[1] || "png"}`, + { type: file.type }, + ), + ); + + const newStagedFiles = files.map(createStagedFile); + setStagedFiles((prev) => [...prev, ...newStagedFiles]); + + try { + for (const stagedFile of newStagedFiles) { + setStagedFiles((prev) => + prev.map((f) => + f.id === stagedFile.id ? { ...f, status: "uploading" } : f, + ), + ); + + const formData = new FormData(); + formData.append("file", stagedFile.file); + formData.append("chatId", chatId); + + const response = await fetch("/api/files/upload", { + method: "POST", + body: formData, + }); + + if (!response.ok) throw new Error("Upload failed"); + + const data = await response.json(); + + setAttachments((current) => [ + ...current, + { + url: data.url, + name: stagedFile.file.name, + contentType: stagedFile.file.type, + path: data.path, + }, + ]); + + setStagedFiles((prev) => + prev.map((f) => + f.id === stagedFile.id ? { ...f, status: "complete" } : f, + ), + ); + removeStagedFile(stagedFile.id); + } + + toast.success("Files uploaded successfully"); + } catch (error) { + console.error("Error uploading files:", error); + toast.error("Failed to upload one or more files"); + + newStagedFiles.forEach((file) => { + setStagedFiles((prev) => + prev.map((f) => + f.id === file.id ? { ...f, status: "error" } : f, + ), + ); + }); + } + } + }, + [chatId, createStagedFile, removeStagedFile, setAttachments], + ); + + const canSend = input.trim().length > 0 || attachments.length > 0; + + return ( +
+ {/* Suggestions */} + + {messages.length === 0 && showSuggestions && ( + + {suggestedActions.map((suggestedAction, index) => ( + 1 ? "hidden sm:block" : "block")} + > + + + ))} + + )} + + + {/* File Input */} + + + {/* Attachments */} + + {(attachments.length > 0 || stagedFiles.length > 0) && ( + + {stagedFiles.map((stagedFile) => ( +
+ removeStagedFile(stagedFile.id)} + /> + {stagedFile.status === "error" && ( +
+ + Upload failed + +
+ )} +
+ ))} + + {attachments.map((attachment) => ( +
+ + setAttachments((current) => + current.filter((a) => a.url !== attachment.url) + ) + } + /> +
+ ))} +
+ )} +
+ + {/* Input Container */} +
+ {/* Attachment Button */} + + + + + +

Attach files

+
+
+ + {/* Text Input */} +