diff --git a/.agent/tasks/task.md b/.agent/tasks/task.md new file mode 100644 index 0000000..043133f --- /dev/null +++ b/.agent/tasks/task.md @@ -0,0 +1,10 @@ +# Task: Re-apply Model Fixes + +## Observations +1. **Gemini Error**: `models/gemini-1.5-pro` 404s. Using `gemini-1.5-pro-latest` previously resolved this (passing the error to the next provider). +2. **OpenAI Error**: `gpt-4-turbo` error ("model does not exist"). Only appeared after Gemini was fixed. + +## Plan +- [ ] Update `app/api/chat/provider.ts` again to use correct model identifiers. + - Gemini: `gemini-1.5-flash-latest`, `gemini-1.5-pro-latest` + - OpenAI: `gpt-4o` (widely available and valid) diff --git a/.env.example b/.env.example index 2d605af..a404af7 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,19 @@ UPSTASH_REDIS_REST_TOKEN=xxxxxxxxx # # Upstash # UPSTASH_REDIS_REST_URL=xxxxxxxxx # UPSTASH_REDIS_REST_TOKEN=xxxxxxxxx + +# AI Gateway (routing / unified LLM access) +AI_GATEWAY_API_KEY=xxxxxxxxx + +# Google Gemini / Generative AI +GEMINI_API_KEY=xxxxxxxxx +GOOGLE_GENERATIVE_AI_API_KEY=xxxxxxxxx + +# OpenAI (GPT models) +OPENAI_API_KEY=xxxxxxxxx + +# Anthropic (Claude models) +ANTHROPIC_API_KEY=xxxxxxxxx + +# Tavily (Search / Web RAG) +TAVILY_API_KEY=xxxxxxxxx diff --git a/README.md b/README.md index c89505d..3112fb5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A modern SaaS template built with Next.js 15, Tailwind CSS v4, Shadcn UI v2, Ups - 🧩 **React Hook Form** - Flexible form validation - âš™ī¸ **Zod** - Schema validation - đŸ›Ąī¸ **Enhanced Security** - Robust authentication with rate limiting using Upstash +- 📚 **RAG (Chat with Data)** - Upload and chat with PDF, DOCX, TXT, and Images using Google Gemini embeddings - 🔒 **Security Headers** - CSP and other security headers (Coming Soon) - đŸšĢ **Anti-Brute Force** - Protection against authentication attacks (Coming Soon) @@ -62,6 +63,7 @@ supabase start ``` > After the contianer starts, you will be provided with some credentials like the following example: +> > ``` > API URL: http://127.0.0.1:54321 > GraphQL URL: http://127.0.0.1:54321/graphql/v1 @@ -76,8 +78,18 @@ supabase start > S3 Secret Key: 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 > S3 Region: local > ``` +> > **Copy those credentials from you terminal** and save them in your notes or create a `supabase-local-credentials.txt` file in this repo (it is already added to `.gitignore` so that it is not pushed into the repository.) +#### Run RAG Migrations + +To enable vector search, you need to apply the RAG database schema: + +1. Go to your Supabase Dashboard (http://127.0.0.1:54323). +2. Open the **SQL Editor**. +3. Open the file `supabase/migrations/20251217_add_rag_columns.sql` from your project. +4. Copy the content and run it in the SQL Editor. + ### 4. Set up environment variables 1. Copy the `.env.example` file to `.env.local`: @@ -99,6 +111,11 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=xxxxxxxxx # Upstash UPSTASH_REDIS_REST_URL=xxxxxxxxx UPSTASH_REDIS_REST_TOKEN=xxxxxxxxx + +# RAG & AI (Google Gemini is required for RAG) +GOOGLE_GENERATIVE_AI_API_KEY=xxxxxxxxx +TAVILY_API_KEY=xxxxxxxxx # Optional, for web search +# OPENAI_API_KEY=xxxxxxxxx # Optional, but Google is preferred for this RAG implementation ``` ### 5. Run the development server @@ -114,7 +131,48 @@ yarn dev Your application should now be running at [http://localhost:3000](http://localhost:3000). +### 6. How to use RAG (Chat with Data) + +1. **Upload Documents**: Click the paperclip icon in the chat input area. +2. **Select Files**: Choose PDF, Word (.docx), Text (.txt), or Image files. +3. **Chat**: Once uploaded, ask questions about your documents. The AI will retrieve relevant context to answer. + +## RAG Architecture + +### Document Ingestion Flow + +```mermaid +graph TD + A["User Uploads File"] --> B["API: /api/process-document"] + B --> C{"File Type?"} + C -- "PDF/DOCX/TXT" --> D["Extract Text"] + C -- "Image" --> E["Gemini Vision Analysis"] + D --> F["Chunk Text"] + E --> F + F --> G["Generate Embeddings (Google/OpenAI)"] + G --> H["Store in Supabase (pgvector)"] +``` + +### Chat Retrieval Flow + +```mermaid +sequenceDiagram + participant User + participant API as Chat API + participant DB as Supabase (Vector DB) + participant LLM as Google Gemini + + User->>API: Send Message + API->>API: Generate Embedding for Query + API->>DB: match_documents(embedding) + DB-->>API: Return Relevant Chunks + API->>API: Inject Context into System Prompt + API->>LLM: Generate Response (Query + Context) + LLM-->>User: Stream Response +``` + ## Some **Features** + - Email/password authentication - Google OAuth integration - Strong password requirements @@ -134,6 +192,10 @@ Your application should now be running at [http://localhost:3000](http://localho │ │ └── reset-password/ │ ├── (public)/ # Public routes │ ├── (authenticated)/ # Protected routes +│ │ └── chat/ # Chat interface +│ ├── api/ # API Routes +│ │ ├── chat/ # Chat API +│ │ └── process-document/# RAG Ingestion API │ ├── actions/ # Server actions │ └── globals.css # Global styles ├── assets/ # Project assets @@ -141,6 +203,7 @@ Your application should now be running at [http://localhost:3000](http://localho │ └── logos/ # Logo files ├── components/ # React components │ ├── ui/ # Shadcn UI components +│ ├── chat/ # Chat components │ ├── mode-toggle.tsx # Dark/light mode toggle │ └── theme-provider.tsx # Theme context provider ├── hooks/ # Custom React hooks diff --git a/app/(authenticated)/chat/[[...sessionId]]/layout.tsx b/app/(authenticated)/chat/[[...sessionId]]/layout.tsx new file mode 100644 index 0000000..a417c25 --- /dev/null +++ b/app/(authenticated)/chat/[[...sessionId]]/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import ChatSidebar from "@/components/chat/ChatSidebar"; + +export default function ChatLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+ +
+ {children} +
+
+ ); +} diff --git a/app/(authenticated)/chat/[[...sessionId]]/page.tsx b/app/(authenticated)/chat/[[...sessionId]]/page.tsx new file mode 100644 index 0000000..e54b2d2 --- /dev/null +++ b/app/(authenticated)/chat/[[...sessionId]]/page.tsx @@ -0,0 +1,365 @@ +'use client'; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from '@/components/ai-elements/conversation'; +import { + Message, + MessageContent, + MessageResponse, + MessageActions, + MessageAction, +} from '@/components/ai-elements/message'; +import { + PromptInput, + PromptInputActionAddAttachments, + PromptInputActionMenu, + PromptInputActionMenuContent, + PromptInputActionMenuTrigger, + PromptInputAttachment, + PromptInputAttachments, + PromptInputBody, + PromptInputButton, + PromptInputHeader, + PromptInputSelect, + PromptInputSelectContent, + PromptInputSelectItem, + PromptInputSelectTrigger, + PromptInputSelectValue, + PromptInputSubmit, + PromptInputTextarea, + PromptInputFooter, + PromptInputTools, +} from '@/components/ai-elements/prompt-input'; +import { useState, useEffect } from 'react'; +import { useChat } from '@ai-sdk/react'; +import { CopyIcon, GlobeIcon, RefreshCcwIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Source, + Sources, + SourcesContent, + SourcesTrigger, +} from '@/components/ai-elements/sources'; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from '@/components/ai-elements/reasoning'; +import { Loader } from '@/components/ai-elements/loader'; +import { useParams, useRouter } from 'next/navigation'; +import { createClient } from '@/utils/supabase/client'; + +const models = [ + { name: 'Gemini (flash)', value: 'gemini' }, + { name: 'Gemini (lite)', value: 'gemini_flash_lite' }, + { name: 'Gemini (pro)', value: 'gemini_pro' }, + { name: 'GPT-4 Turbo', value: 'openai' }, + { name: 'Claude 3.5', value: 'claude' }, +]; + +const ChatBotDemo = () => { + const params = useParams(); + const router = useRouter(); + const sessionId = params.sessionId?.[0]; // Get first item from catch-all route + + const [model, setModel] = useState(models[0].value); + const [input, setInput] = useState(''); + const [webSearch, setWebSearch] = useState(false); + const [isLoadingMessages, setIsLoadingMessages] = useState(!!sessionId); + + // Initialize useChat - attachments are handled automatically by PromptInput components + const { messages, setMessages, sendMessage, status, regenerate } = useChat({}); + + // Load existing messages for conversation context + useEffect(() => { + if (!sessionId) { + setIsLoadingMessages(false); + return; + } + + const loadMessages = async () => { + try { + const response = await fetch(`/api/messages?sessionId=${sessionId}`); + if (response.ok) { + const data = await response.json(); + // Convert database messages to AI SDK format with parts array + const formattedMessages = data.map((msg: any) => ({ + id: msg.id, + role: msg.role, + parts: [ + { + type: 'text', + text: msg.content, + } + ], + })); + // setMessages includes these in conversation context for AI + setMessages(formattedMessages); + } + } catch (error) { + console.error('Error loading messages:', error); + } finally { + setIsLoadingMessages(false); + } + }; + + loadMessages(); + }, [sessionId, setMessages]); + + const handleSubmit = async (message?: { text?: string; files?: any[] }, e?: React.FormEvent) => { + e?.preventDefault(); + const text = message?.text ?? input; + const files = message?.files ?? []; + + if (!text?.trim() && files.length === 0) return; + + // Process files for RAG before sending message + if (files.length > 0) { + const supabase = createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (user) { + // Process each file for RAG in the background + files.forEach(async (file) => { + try { + // Upload to Supabase Storage + // Use 'filename' and 'mediaType' from PromptInput's FileUIPart, fallback to 'name' and 'type' if standard File + const fileName = file.filename || file.name || `file-${Date.now()}`; + const fileType = file.mediaType || file.type || 'application/octet-stream'; + + const fileId = `${Date.now()}-${fileName}`; + const filePath = `${user.id}/${fileId}`; + + // Helper to convert data URL or blob URL to Blob for upload + let blobToUpload = file; + if (!(file instanceof File) && !(file instanceof Blob)) { + // If it's a FileUIPart, we might need to fetch the blob from the URL + if (file.url) { + const blobRes = await fetch(file.url); + blobToUpload = await blobRes.blob(); + } + } + + const { error: uploadError } = await supabase.storage + .from('documents') + .upload(filePath, blobToUpload, { + contentType: fileType, + }); + + if (uploadError) { + console.error('Error uploading file for RAG:', uploadError); + toast.error(`Upload failed: ${uploadError.message}`); + return; + } + + // Process document for RAG + // Remove debug payload log + const response = await fetch('/api/process-document', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filePath, + fileName: fileName, + fileType: fileType, + }), + }); + + const result = await response.json(); + if (response.ok) { + console.log(`✅ RAG: Processed ${fileName} - ${result.chunksProcessed} chunks created`); + toast.success(`${fileName} added to your knowledge base!`); + } else { + console.error('Error processing document:', result.error); + toast.error(`Failed to process ${fileName}: ${result.error}`); + } + } catch (error) { + console.error('Error in RAG processing:', error); + } + }); + } + } + + // Filter files: Only send images to LLM (for multimodal vision) + // Other files (PDFs, Word docs, etc.) only go to RAG, not to LLM + const imageFiles = files.filter((file: File) => file.type.startsWith('image/')); + + await sendMessage( + { + text: text || '', + files: imageFiles, // Only images for multimodal vision, others are RAG-only + }, + { + body: { + modelKey: model, + webSearch, + sessionId, + }, + } + ); + setInput(''); + }; + + function handleInputChange(event: React.ChangeEvent): void { + setInput(event.target.value); + } + + return ( +
+
+ + + {messages.map((message) => ( +
+ {message.role === 'assistant' && message.parts.filter((part) => part.type === 'source-url').length > 0 && ( + + part.type === 'source-url', + ).length + } + /> + {message.parts.filter((part) => part.type === 'source-url').map((part, i) => ( + + + + ))} + + )} + {message.parts.map((part, i) => { + switch (part.type) { + case 'text': + return ( + + + + {part.text} + + + {message.role === 'assistant' && i === message.parts.length - 1 && ( + + regenerate()} + label="Retry" + > + + + { + navigator.clipboard.writeText(part.text); + toast.success('Message copied to clipboard!'); + }} + label="Copy" + > + + + + )} + + ); + case 'file': + return ( + + + {(part as any).contentType?.startsWith('image/') ? ( + {(part + ) : ( +
+ {(part as any).name || 'File'} +
+ )} +
+
+ ); + case 'reasoning': + return ( + + + {part.text} + + ); + default: + return null; + } + })} +
+ ))} + {status === 'submitted' && } +
+ +
+ + + + {(attachment) => } + + + + + + + + + + + + + + setWebSearch(!webSearch)} + > + + Search + + { + setModel(value); + }} + value={model} + > + + + + + {models.map((model) => ( + + {model.name} + + ))} + + + + + + +
+
+ ); +}; + +export default ChatBotDemo; diff --git a/app/api/chat/provider.ts b/app/api/chat/provider.ts new file mode 100644 index 0000000..8b31550 --- /dev/null +++ b/app/api/chat/provider.ts @@ -0,0 +1,11 @@ +import { openai } from '@ai-sdk/openai'; +import { anthropic } from '@ai-sdk/anthropic'; +import { google } from '@ai-sdk/google'; + +export const providers = { + openai: openai('gpt-4o-mini'), + claude: anthropic('claude-3-5-sonnet-20241022'), + gemini: google('gemini-2.5-flash'), + gemini_flash_lite: google('gemini-2.5-flash-lite'), + gemini_pro: google('gemini-2.5-pro'), +}; \ No newline at end of file diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..8b2b9f2 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,294 @@ +import { streamText, convertToModelMessages, stepCountIs } from 'ai'; +import { providers } from './provider'; +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { tavilySearch } from '@tavily/ai-sdk'; +import { google } from '@ai-sdk/google'; +import { embed } from 'ai'; + +export async function POST(req: NextRequest) { + try { + const supabase = await createClient(); + + // Get authenticated user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { messages, modelKey, sessionId, webSearch } = body; + + const model = providers[modelKey as keyof typeof providers]; + if (!model) { + return NextResponse.json({ error: 'Unknown modelKey' }, { status: 400 }); + } + + let currentSessionId = sessionId; + + // If no sessionId provided, create a new session + if (!currentSessionId) { + const { data: newSession, error: sessionError } = await supabase + .from('chat_sessions') + .insert({ + user_id: user.id, + title: 'New conversation', + }) + .select() + .single(); + + if (sessionError || !newSession) { + console.error('Error creating session:', sessionError); + return NextResponse.json({ error: 'Failed to create session' }, { status: 500 }); + } + + currentSessionId = newSession.id; + } + + // Verify session belongs to user + const { data: session, error: verifyError } = await supabase + .from('chat_sessions') + .select('id, title') + .eq('id', currentSessionId) + .eq('user_id', user.id) + .single(); + + if (verifyError || !session) { + return NextResponse.json({ error: 'Session not found or unauthorized' }, { status: 404 }); + } + + // STEP 1: Save user message immediately (before streaming) + const lastMessage = messages[messages.length - 1]; + if (lastMessage && lastMessage.role === 'user') { + // Extract text content from AI SDK message format + let messageText = ''; + + if (lastMessage.parts && Array.isArray(lastMessage.parts)) { + // Handle AI SDK format with parts array + messageText = lastMessage.parts + .filter((part: any) => part.type === 'text') + .map((part: any) => part.text) + .join(' '); + } else if (typeof lastMessage.content === 'string') { + messageText = lastMessage.content; + } else if (Array.isArray(lastMessage.content)) { + // Handle array of content parts + messageText = lastMessage.content + .map((part: any) => (typeof part === 'string' ? part : part.text || '')) + .join(' '); + } else if (lastMessage.content?.text) { + messageText = lastMessage.content.text; + } + + if (!messageText) { + console.error('Cannot extract text from message:', lastMessage); + return NextResponse.json({ error: 'Invalid message format' }, { status: 400 }); + } + + const { error: userMsgError } = await supabase + .from('chat_messages') + .insert({ + session_id: currentSessionId, + role: 'user', + content: messageText, + }); + + if (userMsgError) { + console.error('Error saving user message:', userMsgError); + } + + // Auto-generate title if this is the first message + if (session.title === 'New conversation') { + const generatedTitle = messageText.substring(0, 50); + await supabase + .from('chat_sessions') + .update({ title: generatedTitle }) + .eq('id', currentSessionId); + } + } + + // STEP 2: Stream the AI response + // Track the full response text in case of early abort + let fullResponseText = ''; + + /** + * Sliding Window Function: Prevent token/cost overflows + * - Always includes system prompts at the start + * - Includes only the last N messages (configurable via MAX_MESSAGES_TO_LLM) + * - Frontend still displays full history; only LLM request is truncated + */ + const applyMessageSlidingWindow = (messages: any[], maxMessages: number = 20) => { + if (!messages || messages.length === 0) return []; + + // Separate system prompts from conversation messages + const systemMessages = messages.filter(msg => msg.role === 'system'); + const conversationMessages = messages.filter(msg => msg.role !== 'system'); + + // Take only the last N conversation messages (sliding window) + const recentMessages = conversationMessages.slice(-maxMessages); + + // Combine: system prompts first, then recent conversation + return [...systemMessages, ...recentMessages]; + }; + + // Apply sliding window: keep last 20 messages (10 turns) + system prompts + const truncatedMessages = applyMessageSlidingWindow(messages, 20); + + // RAG ENHANCEMENT: Retrieve relevant document context + let enhancedMessages = truncatedMessages; + if (lastMessage && lastMessage.role === 'user') { + try { + // Extract text from last message (same logic as earlier) + let messageText = ''; + if (lastMessage.parts && Array.isArray(lastMessage.parts)) { + messageText = lastMessage.parts + .filter((part: any) => part.type === 'text') + .map((part: any) => part.text) + .join(' '); + } else if (typeof lastMessage.content === 'string') { + messageText = lastMessage.content; + } else if (Array.isArray(lastMessage.content)) { + messageText = lastMessage.content + .map((part: any) => (typeof part === 'string' ? part : part.text || '')) + .join(' '); + } else if (lastMessage.content?.text) { + messageText = lastMessage.content.text; + } + + if (messageText && messageText.trim().length > 0) { + // Generate embedding for user's message + const embeddingModel = google.textEmbeddingModel('text-embedding-004'); + const { embedding } = await embed({ + model: embeddingModel, + value: messageText, + }); + + // Search for relevant documents using match_documents RPC + const { data: relevantChunks, error: searchError } = await supabase.rpc( + 'match_documents', + { + query_embedding: embedding, + match_threshold: 0.7, + match_count: 5, + filter_user_id: user.id, + } + ); + + if (!searchError && relevantChunks && relevantChunks.length > 0) { + // Format retrieved context + const contextText = relevantChunks + .map((chunk: any, idx: number) => { + return `[Document ${idx + 1}] (Similarity: ${(chunk.similarity * 100).toFixed(1)}%)\n${chunk.content}`; + }) + .join('\n\n'); + + // Create enhanced system message with context + const ragSystemMessage = { + role: 'system', + content: `You are a helpful assistant. The user has uploaded documents to their knowledge base. Use the following context from their documents to answer their question. If the context is relevant, cite it in your answer. If the context is not relevant to the question, answer normally without mentioning the context. + +=== RETRIEVED CONTEXT === +${contextText} +=== END OF CONTEXT === + +Now answer the user's question based on the above context and your general knowledge.`, + }; + + // Inject RAG system message at the beginning + enhancedMessages = [ragSystemMessage, ...truncatedMessages]; + + console.log(`✅ RAG: Found ${relevantChunks.length} relevant chunks for query`); + } else { + console.log('â„šī¸ RAG: No relevant documents found for query'); + } + } + } catch (ragError) { + console.error('Error in RAG retrieval:', ragError); + // Continue without RAG context on error + } + } + + const result = await streamText({ + model, + messages: convertToModelMessages(enhancedMessages), + // Enable web search tool when user toggles search button + tools: webSearch ? { + webSearch: tavilySearch({ + apiKey: process.env.TAVILY_API_KEY!, + maxResults: 5, + }) + } : undefined, + // Multi-step reasoning: allows AI to search, read results, and generate answer + stopWhen: webSearch ? stepCountIs(5) : undefined, + onChunk: ({ chunk }) => { + // Accumulate text as it streams (for partial save on abort) + if (chunk.type === 'text-delta') { + fullResponseText += chunk.text; + } + }, + onFinish: async ({ text, finishReason }) => { + // STEP 3: Save assistant response when streaming completes + // Use the accumulated text (in case text param is empty on abort) + const contentToSave = text || fullResponseText; + + if (!contentToSave) { + console.warn('No content to save in onFinish'); + return; + } + + // Create a promise for the DB save + const savePromise = (async () => { + const { error: assistantMsgError } = await supabase + .from('chat_messages') + .insert({ + session_id: currentSessionId, + role: 'assistant', + content: contentToSave, + }); + + if (assistantMsgError) { + console.error('Error saving assistant message:', assistantMsgError); + } else { + console.log(`Saved assistant message (${contentToSave.length} chars, reason: ${finishReason})`); + } + })(); + + // Extend request lifecycle to complete DB write even if client disconnects + // @ts-ignore - waitUntil may not be in all Next.js versions + if (req.waitUntil) { + // @ts-ignore + req.waitUntil(savePromise); + } else { + // Fallback: await the promise (may not complete on disconnect) + await savePromise; + } + }, + }); + + // Handle request abort to save partial response + req.signal?.addEventListener('abort', async () => { + console.log('Request aborted, saving partial response...'); + if (fullResponseText && fullResponseText.length > 0) { + // Save whatever we have so far + try { + await supabase + .from('chat_messages') + .insert({ + session_id: currentSessionId, + role: 'assistant', + content: fullResponseText + ' [interrupted]', + }); + console.log(`Saved partial response (${fullResponseText.length} chars)`); + } catch (error) { + console.error('Error saving partial response on abort:', error); + } + } + }); + + return result.toUIMessageStreamResponse(); + } catch (error) { + console.error('Unexpected error in chat API:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts new file mode 100644 index 0000000..057485e --- /dev/null +++ b/app/api/conversations/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; + +// GET /api/conversations - Fetch all sessions for the logged-in user +export async function GET() { + try { + const supabase = await createClient(); + + // Get authenticated user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Fetch user's chat sessions with message count + const { data: sessions, error } = await supabase + .from('chat_sessions') + .select(` + id, + title, + created_at, + chat_messages(count) + `) + .eq('user_id', user.id) + .order('created_at', { ascending: false }); + + if (error) { + console.error('Error fetching sessions:', error); + return NextResponse.json({ error: 'Failed to fetch conversations' }, { status: 500 }); + } + + // Transform the response to include message count + const conversations = sessions?.map(session => ({ + id: session.id, + title: session.title || 'New conversation', + created_at: session.created_at, + messageCount: session.chat_messages?.[0]?.count || 0, + })) || []; + + return NextResponse.json(conversations); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// POST /api/conversations - Create a new session +export async function POST(req: NextRequest) { + try { + const supabase = await createClient(); + + // Get authenticated user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { title } = body; + + // Create new session + const { data: session, error } = await supabase + .from('chat_sessions') + .insert({ + user_id: user.id, + title: title || 'New conversation', + }) + .select() + .single(); + + if (error) { + console.error('Error creating session:', error); + return NextResponse.json({ error: 'Failed to create conversation' }, { status: 500 }); + } + + return NextResponse.json({ + id: session.id, + title: session.title, + created_at: session.created_at, + }); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// DELETE /api/conversations?id=[sessionId] - Delete a session +export async function DELETE(req: NextRequest) { + try { + const supabase = await createClient(); + + // Get authenticated user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const sessionId = searchParams.get('id'); + + if (!sessionId) { + return NextResponse.json({ error: 'Session ID is required' }, { status: 400 }); + } + + // Delete session (messages cascade automatically) + const { error } = await supabase + .from('chat_sessions') + .delete() + .eq('id', sessionId) + .eq('user_id', user.id); // Security: ensure user owns this session + + if (error) { + console.error('Error deleting session:', error); + return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..329c975 --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; + +// GET /api/messages?sessionId=[uuid] - Fetch messages for a session +export async function GET(req: NextRequest) { + try { + const supabase = await createClient(); + + // Get authenticated user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const sessionId = searchParams.get('sessionId'); + + if (!sessionId) { + return NextResponse.json({ error: 'Session ID is required' }, { status: 400 }); + } + + // Verify session belongs to user + const { data: session, error: sessionError } = await supabase + .from('chat_sessions') + .select('id') + .eq('id', sessionId) + .eq('user_id', user.id) + .single(); + + if (sessionError || !session) { + return NextResponse.json({ error: 'Session not found or unauthorized' }, { status: 404 }); + } + + // Fetch messages for this session + const { data: messages, error } = await supabase + .from('chat_messages') + .select('id, role, content, created_at') + .eq('session_id', sessionId) + .order('created_at', { ascending: true }); + + if (error) { + console.error('Error fetching messages:', error); + return NextResponse.json({ error: 'Failed to fetch messages' }, { status: 500 }); + } + + return NextResponse.json(messages || []); + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/process-document/route.ts b/app/api/process-document/route.ts new file mode 100644 index 0000000..79359c0 --- /dev/null +++ b/app/api/process-document/route.ts @@ -0,0 +1,193 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { google } from '@ai-sdk/google'; +import { openai } from '@ai-sdk/openai'; +import { embed, embedMany } from 'ai'; +import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; +import mammoth from 'mammoth'; +import { generateText } from 'ai'; + +// Helper to get embedding model +function getEmbeddingModel() { + if (process.env.OPENAI_API_KEY) { + return openai.embedding('text-embedding-3-small'); + } + return google.textEmbeddingModel('text-embedding-004'); +} + +export async function POST(req: NextRequest) { + try { + const supabase = await createClient(); + + // Get authenticated user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + console.log('📝 RAG Process Request Body:', JSON.stringify(body, null, 2)); + const { filePath, fileName, fileType } = body; + + if (!filePath || !fileName) { + console.error('❌ Missing fields:', { filePath, fileName }); + return NextResponse.json({ error: 'Missing filePath or fileName' }, { status: 400 }); + } + + // Download file from Supabase Storage + const { data: fileData, error: downloadError } = await supabase.storage + .from('documents') + .download(filePath); + + if (downloadError || !fileData) { + console.error('Error downloading file:', downloadError); + return NextResponse.json({ error: 'Failed to download file' }, { status: 500 }); + } + + // Convert Blob to Buffer + const arrayBuffer = await fileData.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Step 1: Extract text based on file type + let extractedText = ''; + + try { + if (fileType === 'application/pdf' || fileName.endsWith('.pdf')) { + // PDF extraction - pdf-parse is a CommonJS module + const pdfParse = await import('pdf-parse'); + // @ts-ignore - pdf-parse has complex module exports + const parser = pdfParse.default || pdfParse; + const pdfData = await parser(buffer); + extractedText = pdfData.text; + } else if ( + fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + fileName.endsWith('.docx') + ) { + // Word document extraction + const result = await mammoth.extractRawText({ buffer }); + extractedText = result.value; + } else if (fileType?.startsWith('image/') || /\.(jpg|jpeg|png)$/i.test(fileName)) { + // Image analysis using Gemini Vision + const base64Image = buffer.toString('base64'); + const mimeType = fileType || 'image/jpeg'; + + const { text } = await generateText({ + model: google('gemini-1.5-flash'), + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Analyze this image in detail. Describe all visible text, charts, and data for search purposes.', + }, + { + type: 'image', + image: `data:${mimeType};base64,${base64Image}`, + }, + ], + }, + ], + }); + + extractedText = text; + } else if (fileType === 'text/plain' || fileName.endsWith('.txt')) { + // Plain text file + extractedText = buffer.toString('utf-8'); + } else { + return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }); + } + } catch (extractError) { + console.error('Error extracting text:', extractError); + return NextResponse.json({ error: 'Failed to extract text from file' }, { status: 500 }); + } + + if (!extractedText || extractedText.trim().length === 0) { + return NextResponse.json({ error: 'No text content extracted from file' }, { status: 400 }); + } + + // Step 2: Chunk the text + const splitter = new RecursiveCharacterTextSplitter({ + chunkSize: 1000, + chunkOverlap: 200, + }); + + const chunks = await splitter.splitText(extractedText); + + if (chunks.length === 0) { + return NextResponse.json({ error: 'No chunks created from text' }, { status: 400 }); + } + + // Step 3: Save file metadata to chat_sources + const { data: sourceData, error: sourceError } = await supabase + .from('chat_sources') + .insert({ + user_id: user.id, + name: fileName, + content: extractedText.substring(0, 5000), // Store first 5000 chars as preview + }) + .select() + .single(); + + if (sourceError || !sourceData) { + console.error('Error saving source:', sourceError); + return NextResponse.json({ error: 'Failed to save source' }, { status: 500 }); + } + + // Step 4: Generate embeddings and save chunks + // Use OpenAI if available (bypass Gemini quota limits), otherwise Google + const embeddingModel = getEmbeddingModel(); + + const { embeddings } = await embedMany({ + model: embeddingModel, + values: chunks, + }); + + const embeddingPromises = chunks.map(async (chunk, index) => { + try { + // Get the pre-generated embedding for this chunk + const embedding = embeddings[index]; + + // Save to chat_embeddings + const { error: embeddingError } = await supabase + .from('chat_embeddings') + .insert({ + source_id: sourceData.id, + content: chunk, // Store the chunk text + embedding: embedding, + metadata: { + chunk_index: index, + chunk_length: chunk.length, + file_name: fileName, + }, + }); + + if (embeddingError) { + console.error('Error saving embedding:', embeddingError); + throw embeddingError; + } + + return { success: true, index }; + } catch (error) { + console.error(`Error processing chunk ${index}:`, error); + return { success: false, index, error }; + } + }); + + const results = await Promise.all(embeddingPromises); + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + return NextResponse.json({ + success: true, + sourceId: sourceData.id, + chunksProcessed: successCount, + chunksFailed: failureCount, + totalChunks: chunks.length, + }); + } catch (error) { + console.error('Unexpected error in process-document API:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index cbcb91d..449b929 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { ThemeProvider } from '@/components/theme-provider'; +import { Toaster } from '@/components/ui/sonner'; const inter = Inter({ subsets: ['latin'] }); @@ -23,6 +24,7 @@ export default function RootLayout({ {children} + diff --git a/components/ai-elements/artifact.tsx b/components/ai-elements/artifact.tsx new file mode 100644 index 0000000..c90cb5f --- /dev/null +++ b/components/ai-elements/artifact.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { type LucideIcon, XIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type ArtifactProps = HTMLAttributes; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = "sm", + variant = "ghost", + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = "sm", + variant = "ghost", + ...props +}: ArtifactActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/components/ai-elements/canvas.tsx b/components/ai-elements/canvas.tsx new file mode 100644 index 0000000..5aa83cb --- /dev/null +++ b/components/ai-elements/canvas.tsx @@ -0,0 +1,22 @@ +import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; +import type { ReactNode } from "react"; +import "@xyflow/react/dist/style.css"; + +type CanvasProps = ReactFlowProps & { + children?: ReactNode; +}; + +export const Canvas = ({ children, ...props }: CanvasProps) => ( + + + {children} + +); diff --git a/components/ai-elements/chain-of-thought.tsx b/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 0000000..457186f --- /dev/null +++ b/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { + BrainIcon, + ChevronDownIcon, + DotIcon, + type LucideIcon, +} from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, memo, useContext, useMemo } from "react"; + +type ChainOfThoughtContextValue = { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +const ChainOfThoughtContext = createContext( + null +); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error( + "ChainOfThought components must be used within ChainOfThought" + ); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<"div"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export const ChainOfThought = memo( + ({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props + }: ChainOfThoughtProps) => { + const [isOpen, setIsOpen] = useControllableState({ + prop: open, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const chainOfThoughtContext = useMemo( + () => ({ isOpen, setIsOpen }), + [isOpen, setIsOpen] + ); + + return ( + +
+ {children} +
+
+ ); + } +); + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export const ChainOfThoughtHeader = memo( + ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { + const { isOpen, setIsOpen } = useChainOfThought(); + + return ( + + + + + {children ?? "Chain of Thought"} + + + + + ); + } +); + +export type ChainOfThoughtStepProps = ComponentProps<"div"> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: "complete" | "active" | "pending"; +}; + +export const ChainOfThoughtStep = memo( + ({ + className, + icon: Icon = DotIcon, + label, + description, + status = "complete", + children, + ...props + }: ChainOfThoughtStepProps) => { + const statusStyles = { + complete: "text-muted-foreground", + active: "text-foreground", + pending: "text-muted-foreground/50", + }; + + return ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ); + } +); + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export const ChainOfThoughtSearchResults = memo( + ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( +
+ ) +); + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export const ChainOfThoughtSearchResult = memo( + ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( + + {children} + + ) +); + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export const ChainOfThoughtContent = memo( + ({ className, children, ...props }: ChainOfThoughtContentProps) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {children} + + + ); + } +); + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export const ChainOfThoughtImage = memo( + ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ) +); + +ChainOfThought.displayName = "ChainOfThought"; +ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; +ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; +ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; +ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; +ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; +ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; diff --git a/components/ai-elements/checkpoint.tsx b/components/ai-elements/checkpoint.tsx new file mode 100644 index 0000000..d9a5d32 --- /dev/null +++ b/components/ai-elements/checkpoint.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { BookmarkIcon, type LucideProps } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type CheckpointProps = HTMLAttributes; + +export const Checkpoint = ({ + className, + children, + ...props +}: CheckpointProps) => ( +
+ {children} + +
+); + +export type CheckpointIconProps = LucideProps; + +export const CheckpointIcon = ({ + className, + children, + ...props +}: CheckpointIconProps) => + children ?? ( + + ); + +export type CheckpointTriggerProps = ComponentProps & { + tooltip?: string; +}; + +export const CheckpointTrigger = ({ + children, + className, + variant = "ghost", + size = "sm", + tooltip, + ...props +}: CheckpointTriggerProps) => + tooltip ? ( + + + + + + {tooltip} + + + ) : ( + + ); diff --git a/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx new file mode 100644 index 0000000..6fce420 --- /dev/null +++ b/components/ai-elements/code-block.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +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"; + +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +type CodeBlockContextType = { + code: string; +}; + +const CodeBlockContext = createContext({ + 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(""); + const [darkHtml, setDarkHtml] = useState(""); + 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 ( + +
+
+
+
+ {children && ( +
+ {children} +
+ )} +
+
+ + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + 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 ( + + ); +}; diff --git a/components/ai-elements/confirmation.tsx b/components/ai-elements/confirmation.tsx new file mode 100644 index 0000000..2ec0aab --- /dev/null +++ b/components/ai-elements/confirmation.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ToolUIPart } from "ai"; +import { + type ComponentProps, + createContext, + type ReactNode, + useContext, +} from "react"; + +type ToolUIPartApproval = + | { + id: string; + approved?: never; + reason?: never; + } + | { + id: string; + approved: boolean; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: false; + reason?: string; + } + | undefined; + +type ConfirmationContextValue = { + approval: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +const ConfirmationContext = createContext( + null +); + +const useConfirmation = () => { + const context = useContext(ConfirmationContext); + + if (!context) { + throw new Error("Confirmation components must be used within Confirmation"); + } + + return context; +}; + +export type ConfirmationProps = ComponentProps & { + approval?: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +export const Confirmation = ({ + className, + approval, + state, + ...props +}: ConfirmationProps) => { + if (!approval || state === "input-streaming" || state === "input-available") { + return null; + } + + return ( + + + + ); +}; + +export type ConfirmationTitleProps = ComponentProps; + +export const ConfirmationTitle = ({ + className, + ...props +}: ConfirmationTitleProps) => ( + +); + +export type ConfirmationRequestProps = { + children?: ReactNode; +}; + +export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + if (state !== "approval-requested") { + return null; + } + + return children; +}; + +export type ConfirmationAcceptedProps = { + children?: ReactNode; +}; + +export const ConfirmationAccepted = ({ + children, +}: ConfirmationAcceptedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when approved and in response states + if ( + !approval?.approved || + (state !== "approval-responded" && + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationRejectedProps = { + children?: ReactNode; +}; + +export const ConfirmationRejected = ({ + children, +}: ConfirmationRejectedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when rejected and in response states + if ( + approval?.approved !== false || + (state !== "approval-responded" && + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationActionsProps = ComponentProps<"div">; + +export const ConfirmationActions = ({ + className, + ...props +}: ConfirmationActionsProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + if (state !== "approval-requested") { + return null; + } + + return ( +
+ ); +}; + +export type ConfirmationActionProps = ComponentProps; + +export const ConfirmationAction = (props: ConfirmationActionProps) => ( + + )} + + ); +}; + +export type ContextContentProps = ComponentProps; + +export const ContextContent = ({ + className, + ...props +}: ContextContentProps) => ( + +); + +export type ContextContentHeaderProps = ComponentProps<"div">; + +export const ContextContentHeader = ({ + children, + className, + ...props +}: ContextContentHeaderProps) => { + const { usedTokens, maxTokens } = useContextValue(); + const usedPercent = usedTokens / maxTokens; + const displayPct = new Intl.NumberFormat("en-US", { + style: "percent", + maximumFractionDigits: 1, + }).format(usedPercent); + const used = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(usedTokens); + const total = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(maxTokens); + + return ( +
+ {children ?? ( + <> +
+

{displayPct}

+

+ {used} / {total} +

+
+
+ +
+ + )} +
+ ); +}; + +export type ContextContentBodyProps = ComponentProps<"div">; + +export const ContextContentBody = ({ + children, + className, + ...props +}: ContextContentBodyProps) => ( +
+ {children} +
+); + +export type ContextContentFooterProps = ComponentProps<"div">; + +export const ContextContentFooter = ({ + children, + className, + ...props +}: ContextContentFooterProps) => { + const { modelId, usage } = useContextValue(); + const costUSD = modelId + ? getUsage({ + modelId, + usage: { + input: usage?.inputTokens ?? 0, + output: usage?.outputTokens ?? 0, + }, + }).costUSD?.totalUSD + : undefined; + const totalCost = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(costUSD ?? 0); + + return ( +
+ {children ?? ( + <> + Total cost + {totalCost} + + )} +
+ ); +}; + +export type ContextInputUsageProps = ComponentProps<"div">; + +export const ContextInputUsage = ({ + className, + children, + ...props +}: ContextInputUsageProps) => { + const { usage, modelId } = useContextValue(); + const inputTokens = usage?.inputTokens ?? 0; + + if (children) { + return children; + } + + if (!inputTokens) { + return null; + } + + const inputCost = modelId + ? getUsage({ + modelId, + usage: { input: inputTokens, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const inputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(inputCost ?? 0); + + return ( +
+ Input + +
+ ); +}; + +export type ContextOutputUsageProps = ComponentProps<"div">; + +export const ContextOutputUsage = ({ + className, + children, + ...props +}: ContextOutputUsageProps) => { + const { usage, modelId } = useContextValue(); + const outputTokens = usage?.outputTokens ?? 0; + + if (children) { + return children; + } + + if (!outputTokens) { + return null; + } + + const outputCost = modelId + ? getUsage({ + modelId, + usage: { input: 0, output: outputTokens }, + }).costUSD?.totalUSD + : undefined; + const outputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(outputCost ?? 0); + + return ( +
+ Output + +
+ ); +}; + +export type ContextReasoningUsageProps = ComponentProps<"div">; + +export const ContextReasoningUsage = ({ + className, + children, + ...props +}: ContextReasoningUsageProps) => { + const { usage, modelId } = useContextValue(); + const reasoningTokens = usage?.reasoningTokens ?? 0; + + if (children) { + return children; + } + + if (!reasoningTokens) { + return null; + } + + const reasoningCost = modelId + ? getUsage({ + modelId, + usage: { reasoningTokens }, + }).costUSD?.totalUSD + : undefined; + const reasoningCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(reasoningCost ?? 0); + + return ( +
+ Reasoning + +
+ ); +}; + +export type ContextCacheUsageProps = ComponentProps<"div">; + +export const ContextCacheUsage = ({ + className, + children, + ...props +}: ContextCacheUsageProps) => { + const { usage, modelId } = useContextValue(); + const cacheTokens = usage?.cachedInputTokens ?? 0; + + if (children) { + return children; + } + + if (!cacheTokens) { + return null; + } + + const cacheCost = modelId + ? getUsage({ + modelId, + usage: { cacheReads: cacheTokens, input: 0, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const cacheCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cacheCost ?? 0); + + return ( +
+ Cache + +
+ ); +}; + +const TokensWithCost = ({ + tokens, + costText, +}: { + tokens?: number; + costText?: string; +}) => ( + + {tokens === undefined + ? "—" + : new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(tokens)} + {costText ? ( + â€ĸ {costText} + ) : null} + +); diff --git a/components/ai-elements/controls.tsx b/components/ai-elements/controls.tsx new file mode 100644 index 0000000..770a826 --- /dev/null +++ b/components/ai-elements/controls.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Controls as ControlsPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +export type ControlsProps = ComponentProps; + +export const Controls = ({ className, ...props }: ControlsProps) => ( + button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!", + className + )} + {...props} + /> +); diff --git a/components/ai-elements/conversation.tsx b/components/ai-elements/conversation.tsx new file mode 100644 index 0000000..aa380f5 --- /dev/null +++ b/components/ai-elements/conversation.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/components/ai-elements/edge.tsx b/components/ai-elements/edge.tsx new file mode 100644 index 0000000..3cec409 --- /dev/null +++ b/components/ai-elements/edge.tsx @@ -0,0 +1,140 @@ +import { + BaseEdge, + type EdgeProps, + getBezierPath, + getSimpleBezierPath, + type InternalNode, + type Node, + Position, + useInternalNode, +} from "@xyflow/react"; + +const Temporary = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: EdgeProps) => { + const [edgePath] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +const getHandleCoordsByPosition = ( + node: InternalNode, + handlePosition: Position +) => { + // Choose the handle type based on position - Left is for target, Right is for source + const handleType = handlePosition === Position.Left ? "target" : "source"; + + const handle = node.internals.handleBounds?.[handleType]?.find( + (h) => h.position === handlePosition + ); + + if (!handle) { + return [0, 0] as const; + } + + let offsetX = handle.width / 2; + let offsetY = handle.height / 2; + + // this is a tiny detail to make the markerEnd of an edge visible. + // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset + // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position + switch (handlePosition) { + case Position.Left: + offsetX = 0; + break; + case Position.Right: + offsetX = handle.width; + break; + case Position.Top: + offsetY = 0; + break; + case Position.Bottom: + offsetY = handle.height; + break; + default: + throw new Error(`Invalid handle position: ${handlePosition}`); + } + + const x = node.internals.positionAbsolute.x + handle.x + offsetX; + const y = node.internals.positionAbsolute.y + handle.y + offsetY; + + return [x, y] as const; +}; + +const getEdgeParams = ( + source: InternalNode, + target: InternalNode +) => { + const sourcePos = Position.Right; + const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); + const targetPos = Position.Left; + const [tx, ty] = getHandleCoordsByPosition(target, targetPos); + + return { + sx, + sy, + tx, + ty, + sourcePos, + targetPos, + }; +}; + +const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!(sourceNode && targetNode)) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetX: tx, + targetY: ty, + targetPosition: targetPos, + }); + + return ( + <> + + + + + + ); +}; + +export const Edge = { + Temporary, + Animated, +}; diff --git a/components/ai-elements/image.tsx b/components/ai-elements/image.tsx new file mode 100644 index 0000000..542812a --- /dev/null +++ b/components/ai-elements/image.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/lib/utils"; +import type { Experimental_GeneratedImage } from "ai"; + +export type ImageProps = Experimental_GeneratedImage & { + className?: string; + alt?: string; +}; + +export const Image = ({ + base64, + uint8Array, + mediaType, + ...props +}: ImageProps) => ( + {props.alt} +); diff --git a/components/ai-elements/inline-citation.tsx b/components/ai-elements/inline-citation.tsx new file mode 100644 index 0000000..5977081 --- /dev/null +++ b/components/ai-elements/inline-citation.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +export type InlineCitationProps = ComponentProps<"span">; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<"span">; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; +}; + +export const InlineCitationCardTrigger = ({ + sources, + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {sources[0] ? ( + <> + {new URL(sources[0]).hostname}{" "} + {sources.length > 1 && `+${sources.length - 1}`} + + ) : ( + "unknown" + )} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<"div">; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +const CarouselApiContext = createContext(undefined); + +const useCarouselApi = () => { + const context = useContext(CarouselApiContext); + return context; +}; + +export type InlineCitationCarouselProps = ComponentProps; + +export const InlineCitationCarousel = ({ + className, + children, + ...props +}: InlineCitationCarouselProps) => { + const [api, setApi] = useState(); + + return ( + + + {children} + + + ); +}; + +export type InlineCitationCarouselContentProps = ComponentProps<"div">; + +export const InlineCitationCarouselContent = ( + props: InlineCitationCarouselContentProps +) => ; + +export type InlineCitationCarouselItemProps = ComponentProps<"div">; + +export const InlineCitationCarouselItem = ({ + className, + ...props +}: InlineCitationCarouselItemProps) => ( + +); + +export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; + +export const InlineCitationCarouselHeader = ({ + className, + ...props +}: InlineCitationCarouselHeaderProps) => ( +
+); + +export type InlineCitationCarouselIndexProps = ComponentProps<"div">; + +export const InlineCitationCarouselIndex = ({ + children, + className, + ...props +}: InlineCitationCarouselIndexProps) => { + const api = useCarouselApi(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!api) { + return; + } + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + api.on("select", () => { + setCurrent(api.selectedScrollSnap() + 1); + }); + }, [api]); + + return ( +
+ {children ?? `${current}/${count}`} +
+ ); +}; + +export type InlineCitationCarouselPrevProps = ComponentProps<"button">; + +export const InlineCitationCarouselPrev = ({ + className, + ...props +}: InlineCitationCarouselPrevProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollPrev(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationCarouselNextProps = ComponentProps<"button">; + +export const InlineCitationCarouselNext = ({ + className, + ...props +}: InlineCitationCarouselNextProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollNext(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationSourceProps = ComponentProps<"div"> & { + title?: string; + url?: string; + description?: string; +}; + +export const InlineCitationSource = ({ + title, + url, + description, + className, + children, + ...props +}: InlineCitationSourceProps) => ( +
+ {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {description && ( +

+ {description} +

+ )} + {children} +
+); + +export type InlineCitationQuoteProps = ComponentProps<"blockquote">; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); diff --git a/components/ai-elements/loader.tsx b/components/ai-elements/loader.tsx new file mode 100644 index 0000000..5f0cfce --- /dev/null +++ b/components/ai-elements/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from "@/lib/utils"; +import type { HTMLAttributes } from "react"; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx new file mode 100644 index 0000000..5c164c7 --- /dev/null +++ b/components/ai-elements/message.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + ButtonGroup, + ButtonGroupText, +} from "@/components/ui/button-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, UIMessage } from "ai"; +import { + ChevronLeftIcon, + ChevronRightIcon, + PaperclipIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { createContext, memo, useContext, useEffect, useState } from "react"; +import { Streamdown } from "streamdown"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<"div">; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = "ghost", + size = "icon-sm", + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +type MessageBranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const MessageBranchContext = createContext( + null +); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error( + "MessageBranch components must be used within MessageBranch" + ); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }; + + const goToPrevious = () => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }; + + const goToNext = () => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }; + + const contextValue: MessageBranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0", className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = Array.isArray(children) ? children : [children]; + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0", + index === currentBranch ? "block" : "hidden" + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const MessageBranchSelector = ({ + className, + from, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ + children, + className, + ...props +}: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ + className, + ...props +}: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + {...props} + /> + ), + (prevProps, nextProps) => prevProps.children === nextProps.children +); + +MessageResponse.displayName = "MessageResponse"; + +export type MessageAttachmentProps = HTMLAttributes & { + data: FileUIPart; + className?: string; + onRemove?: () => void; +}; + +export function MessageAttachment({ + data, + className, + onRemove, + ...props +}: MessageAttachmentProps) { + const filename = data.filename || ""; + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( +
+ {isImage ? ( + <> + {filename + {onRemove && ( + + )} + + ) : ( + <> + + +
+ +
+
+ +

{attachmentLabel}

+
+
+ {onRemove && ( + + )} + + )} +
+ ); +} + +export type MessageAttachmentsProps = ComponentProps<"div">; + +export function MessageAttachments({ + children, + className, + ...props +}: MessageAttachmentsProps) { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +export type MessageToolbarProps = ComponentProps<"div">; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx new file mode 100644 index 0000000..ef6ebd7 --- /dev/null +++ b/components/ai-elements/model-selector.tsx @@ -0,0 +1,205 @@ +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { ComponentProps, ReactNode } from "react"; + +export type ModelSelectorProps = ComponentProps; + +export const ModelSelector = (props: ModelSelectorProps) => ( + +); + +export type ModelSelectorTriggerProps = ComponentProps; + +export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( + +); + +export type ModelSelectorContentProps = ComponentProps & { + title?: ReactNode; +}; + +export const ModelSelectorContent = ({ + className, + children, + title = "Model Selector", + ...props +}: ModelSelectorContentProps) => ( + + {title} + + {children} + + +); + +export type ModelSelectorDialogProps = ComponentProps; + +export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( + +); + +export type ModelSelectorInputProps = ComponentProps; + +export const ModelSelectorInput = ({ + className, + ...props +}: ModelSelectorInputProps) => ( + +); + +export type ModelSelectorListProps = ComponentProps; + +export const ModelSelectorList = (props: ModelSelectorListProps) => ( + +); + +export type ModelSelectorEmptyProps = ComponentProps; + +export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( + +); + +export type ModelSelectorGroupProps = ComponentProps; + +export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( + +); + +export type ModelSelectorItemProps = ComponentProps; + +export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( + +); + +export type ModelSelectorShortcutProps = ComponentProps; + +export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( + +); + +export type ModelSelectorSeparatorProps = ComponentProps< + typeof CommandSeparator +>; + +export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( + +); + +export type ModelSelectorLogoProps = Omit< + ComponentProps<"img">, + "src" | "alt" +> & { + provider: + | "moonshotai-cn" + | "lucidquery" + | "moonshotai" + | "zai-coding-plan" + | "alibaba" + | "xai" + | "vultr" + | "nvidia" + | "upstage" + | "groq" + | "github-copilot" + | "mistral" + | "vercel" + | "nebius" + | "deepseek" + | "alibaba-cn" + | "google-vertex-anthropic" + | "venice" + | "chutes" + | "cortecs" + | "github-models" + | "togetherai" + | "azure" + | "baseten" + | "huggingface" + | "opencode" + | "fastrouter" + | "google" + | "google-vertex" + | "cloudflare-workers-ai" + | "inception" + | "wandb" + | "openai" + | "zhipuai-coding-plan" + | "perplexity" + | "openrouter" + | "zenmux" + | "v0" + | "iflowcn" + | "synthetic" + | "deepinfra" + | "zhipuai" + | "submodel" + | "zai" + | "inference" + | "requesty" + | "morph" + | "lmstudio" + | "anthropic" + | "aihubmix" + | "fireworks-ai" + | "modelscope" + | "llama" + | "scaleway" + | "amazon-bedrock" + | "cerebras" + | (string & {}); +}; + +export const ModelSelectorLogo = ({ + provider, + className, + ...props +}: ModelSelectorLogoProps) => ( + {`${provider} +); + +export type ModelSelectorLogoGroupProps = ComponentProps<"div">; + +export const ModelSelectorLogoGroup = ({ + className, + ...props +}: ModelSelectorLogoGroupProps) => ( +
img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground", + className + )} + {...props} + /> +); + +export type ModelSelectorNameProps = ComponentProps<"span">; + +export const ModelSelectorName = ({ + className, + ...props +}: ModelSelectorNameProps) => ( + +); diff --git a/components/ai-elements/node.tsx b/components/ai-elements/node.tsx new file mode 100644 index 0000000..75ac59a --- /dev/null +++ b/components/ai-elements/node.tsx @@ -0,0 +1,71 @@ +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Handle, Position } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +export type NodeProps = ComponentProps & { + handles: { + target: boolean; + source: boolean; + }; +}; + +export const Node = ({ handles, className, ...props }: NodeProps) => ( + + {handles.target && } + {handles.source && } + {props.children} + +); + +export type NodeHeaderProps = ComponentProps; + +export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( + +); + +export type NodeTitleProps = ComponentProps; + +export const NodeTitle = (props: NodeTitleProps) => ; + +export type NodeDescriptionProps = ComponentProps; + +export const NodeDescription = (props: NodeDescriptionProps) => ( + +); + +export type NodeActionProps = ComponentProps; + +export const NodeAction = (props: NodeActionProps) => ; + +export type NodeContentProps = ComponentProps; + +export const NodeContent = ({ className, ...props }: NodeContentProps) => ( + +); + +export type NodeFooterProps = ComponentProps; + +export const NodeFooter = ({ className, ...props }: NodeFooterProps) => ( + +); diff --git a/components/ai-elements/open-in-chat.tsx b/components/ai-elements/open-in-chat.tsx new file mode 100644 index 0000000..0c62a6a --- /dev/null +++ b/components/ai-elements/open-in-chat.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { + ChevronDownIcon, + ExternalLinkIcon, + MessageCircleIcon, +} from "lucide-react"; +import { type ComponentProps, createContext, useContext } from "react"; + +const providers = { + github: { + title: "Open in GitHub", + createUrl: (url: string) => url, + icon: ( + + GitHub + + + ), + }, + scira: { + title: "Open in Scira", + createUrl: (q: string) => + `https://scira.ai/?${new URLSearchParams({ + q, + })}`, + icon: ( + + Scira AI + + + + + + + + + ), + }, + chatgpt: { + title: "Open in ChatGPT", + createUrl: (prompt: string) => + `https://chatgpt.com/?${new URLSearchParams({ + hints: "search", + prompt, + })}`, + icon: ( + + OpenAI + + + ), + }, + claude: { + title: "Open in Claude", + createUrl: (q: string) => + `https://claude.ai/new?${new URLSearchParams({ + q, + })}`, + icon: ( + + Claude + + + ), + }, + t3: { + title: "Open in T3 Chat", + createUrl: (q: string) => + `https://t3.chat/new?${new URLSearchParams({ + q, + })}`, + icon: , + }, + v0: { + title: "Open in v0", + createUrl: (q: string) => + `https://v0.app?${new URLSearchParams({ + q, + })}`, + icon: ( + + v0 + + + + ), + }, + cursor: { + title: "Open in Cursor", + createUrl: (text: string) => { + const url = new URL("https://cursor.com/link/prompt"); + url.searchParams.set("text", text); + return url.toString(); + }, + icon: ( + + Cursor + + + ), + }, +}; + +const OpenInContext = createContext<{ query: string } | undefined>(undefined); + +const useOpenInContext = () => { + const context = useContext(OpenInContext); + if (!context) { + throw new Error("OpenIn components must be used within an OpenIn provider"); + } + return context; +}; + +export type OpenInProps = ComponentProps & { + query: string; +}; + +export const OpenIn = ({ query, ...props }: OpenInProps) => ( + + + +); + +export type OpenInContentProps = ComponentProps; + +export const OpenInContent = ({ className, ...props }: OpenInContentProps) => ( + +); + +export type OpenInItemProps = ComponentProps; + +export const OpenInItem = (props: OpenInItemProps) => ( + +); + +export type OpenInLabelProps = ComponentProps; + +export const OpenInLabel = (props: OpenInLabelProps) => ( + +); + +export type OpenInSeparatorProps = ComponentProps; + +export const OpenInSeparator = (props: OpenInSeparatorProps) => ( + +); + +export type OpenInTriggerProps = ComponentProps; + +export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => ( + + {children ?? ( + + )} + +); + +export type OpenInChatGPTProps = ComponentProps; + +export const OpenInChatGPT = (props: OpenInChatGPTProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.chatgpt.icon} + {providers.chatgpt.title} + + + + ); +}; + +export type OpenInClaudeProps = ComponentProps; + +export const OpenInClaude = (props: OpenInClaudeProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.claude.icon} + {providers.claude.title} + + + + ); +}; + +export type OpenInT3Props = ComponentProps; + +export const OpenInT3 = (props: OpenInT3Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.t3.icon} + {providers.t3.title} + + + + ); +}; + +export type OpenInSciraProps = ComponentProps; + +export const OpenInScira = (props: OpenInSciraProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.scira.icon} + {providers.scira.title} + + + + ); +}; + +export type OpenInv0Props = ComponentProps; + +export const OpenInv0 = (props: OpenInv0Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.v0.icon} + {providers.v0.title} + + + + ); +}; + +export type OpenInCursorProps = ComponentProps; + +export const OpenInCursor = (props: OpenInCursorProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.cursor.icon} + {providers.cursor.title} + + + + ); +}; diff --git a/components/ai-elements/panel.tsx b/components/ai-elements/panel.tsx new file mode 100644 index 0000000..059cb7a --- /dev/null +++ b/components/ai-elements/panel.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import { Panel as PanelPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +type PanelProps = ComponentProps; + +export const Panel = ({ className, ...props }: PanelProps) => ( + +); diff --git a/components/ai-elements/plan.tsx b/components/ai-elements/plan.tsx new file mode 100644 index 0000000..be04d88 --- /dev/null +++ b/components/ai-elements/plan.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { ChevronsUpDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { createContext, useContext } from "react"; +import { Shimmer } from "./shimmer"; + +type PlanContextValue = { + isStreaming: boolean; +}; + +const PlanContext = createContext(null); + +const usePlan = () => { + const context = useContext(PlanContext); + if (!context) { + throw new Error("Plan components must be used within Plan"); + } + return context; +}; + +export type PlanProps = ComponentProps & { + isStreaming?: boolean; +}; + +export const Plan = ({ + className, + isStreaming = false, + children, + ...props +}: PlanProps) => ( + + + {children} + + +); + +export type PlanHeaderProps = ComponentProps; + +export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => ( + +); + +export type PlanTitleProps = Omit< + ComponentProps, + "children" +> & { + children: string; +}; + +export const PlanTitle = ({ children, ...props }: PlanTitleProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanDescriptionProps = Omit< + ComponentProps, + "children" +> & { + children: string; +}; + +export const PlanDescription = ({ + className, + children, + ...props +}: PlanDescriptionProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanActionProps = ComponentProps; + +export const PlanAction = (props: PlanActionProps) => ( + +); + +export type PlanContentProps = ComponentProps; + +export const PlanContent = (props: PlanContentProps) => ( + + + +); + +export type PlanFooterProps = ComponentProps<"div">; + +export const PlanFooter = (props: PlanFooterProps) => ( + +); + +export type PlanTriggerProps = ComponentProps; + +export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => ( + + + +); diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000..9c94197 --- /dev/null +++ b/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1378 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { ChatStatus, FileUIPart } from "ai"; +import { + CornerDownLeftIcon, + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import { + type ChangeEvent, + type ChangeEventHandler, + Children, + type ClipboardEventHandler, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type PropsWithChildren, + type ReactNode, + type RefObject, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +export type TextInputContext = { + value: string; + setInput: (v: string) => void; + clear: () => void; +}; + +export type PromptInputControllerProps = { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void + ) => void; +}; + +const PromptInputController = createContext( + null +); +const ProviderAttachmentsContext = createContext( + null +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController()." + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments()." + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export function PromptInputProvider({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachements, setAttachements] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = Array.from(files); + if (incoming.length === 0) { + return; + } + + setAttachements((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })) + ) + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachements((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachements((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachements, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachements, add, remove, clear, openFileDialog] + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [] + ); + + const controller = useMemo( + () => ({ + textInput: { + value: textInput, + setInput: setTextInput, + clear: clearInput, + }, + attachments, + __registerFileInput, + }), + [textInput, clearInput, attachments, __registerFileInput] + ); + + return ( + + + {children} + + + ); +} + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Dual-mode: prefer provider if present, otherwise use local + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = provider ?? local; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" + ); + } + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ""; + + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? "Image" : "Attachment")} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + "children" +> & { + children: (attachment: FileUIPart & { id: string }) => ReactNode; +}; + +export function PromptInputAttachments({ + children, + className, + ...props +}: PromptInputAttachmentsProps) { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return ( +
+ {attachments.files.map((file) => ( + {children(file)} + ))} +
+ ); +} + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + return ( + { + e.preventDefault(); + attachments.openFileDialog(); + }} + > + {label} + + ); +}; + +export type PromptInputMessage = { + text: string; + files: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const anchorRef = useRef(null); + const formRef = useRef(null); + + // Find nearest form to scope drag & drop + useEffect(() => { + const root = anchorRef.current?.closest("form"); + if (root instanceof HTMLFormElement) { + formRef.current = root; + } + }, []); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + if (accept.includes("image/*")) { + return f.type.startsWith("image/"); + } + // NOTE: keep simple; expand as needed + return true; + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const add = usingProvider + ? (files: File[] | FileList) => controller.attachments.add(files) + : addLocal; + + const remove = usingProvider + ? (id: string) => controller.attachments.remove(id) + : (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }); + + const clear = usingProvider + ? () => controller.attachments.clear() + : () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }); + + const openFileDialog = usingProvider + ? () => controller.attachments.openFileDialog() + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of files) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + [usingProvider, files] + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + }; + + const convertBlobUrlToDataUrl = async (url: string): Promise => { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }; + + const ctx = useMemo( + () => ({ + files: files.map((item) => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [files, add, remove, clear, openFileDialog] + ); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + // Convert blob URLs to data URLs asynchronously + Promise.all( + files.map(async ({ id, ...item }) => { + if (item.url && item.url.startsWith("blob:")) { + return { + ...item, + url: await convertBlobUrlToDataUrl(item.url), + }; + } + return item; + }) + ).then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch (error) { + // Don't clear on error - user may want to retry + } + }); + }; + + // Render with or without local provider + const inner = ( + <> +