diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 8722d7d..a29fd40 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,100 +1,11 @@ "use client"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; -import { Loader2 } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { signIn } from "@/db/auth"; +import { SignIn } from "@clerk/nextjs"; export default function LoginPage() { - const [isLoading, setIsLoading] = useState(false); - const [isTransitioning, setIsTransitioning] = useState(false); - const router = useRouter(); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setIsLoading(true); - - try { - const formData = new FormData(event.currentTarget); - const email = formData.get("email") as string; - const password = formData.get("password") as string; - - await signIn(email, password); - setIsTransitioning(true); - router.push("/"); - router.refresh(); - } catch (error: any) { - toast.error(error.message); - setIsLoading(false); - } - } - - if (isTransitioning) { - return ( -
-
- -

Redirecting...

-
-
- ); - } - - return ( -
-
-
-

Login

-

- Enter your email below to login to your account -

-
-
-
- - -
-
- - -
- -
-
- Don't have an account?{" "} - - Register - -
-
-
- ); + return ( +
+ +
+ ); } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index d730d7f..f690381 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,73 +1,11 @@ "use client"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { signUp } from "@/db/auth"; +import { SignUp } from "@clerk/nextjs"; export default function RegisterPage() { - const [isLoading, setIsLoading] = useState(false); - const router = useRouter(); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setIsLoading(true); - - try { - const formData = new FormData(event.currentTarget); - const email = formData.get("email") as string; - const password = formData.get("password") as string; - - await signUp(email, password); - toast.success("Check your email to confirm your account"); - router.push("/login"); - } catch (error: any) { - toast.error(error.message); - } finally { - setIsLoading(false); - } - } - - return ( -
-
-
-

Register

-

- Enter your information to create an account -

-
-
-
- - -
-
- - -
- -
-
- Already have an account?{" "} - - Login - -
-
-
- ); + return ( +
+ +
+ ); } diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index d3f31aa..2c441f4 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -36,7 +36,6 @@ import { kv } from "@vercel/kv"; import { useAccount, useBalance, useChainId } from "wagmi"; import { generateTitleFromUserMessage } from "../../actions"; - export const maxDuration = 60; interface WeatherParams { @@ -87,7 +86,7 @@ const allTools: AllowedTools[] = [ ...weatherTools, "getWalletBalance" as AllowedTools, "checkWalletState" as AllowedTools, - ...(FEATURES.WEB_SEARCH ? ["webSearch" as AllowedTools] : []), + "webSearch" as AllowedTools, ]; async function getUser() { @@ -545,54 +544,49 @@ const tools = { } }, }, - ...(FEATURES.WEB_SEARCH - ? { - webSearch: { - description: "Search the web using DuckDuckGo", - parameters: z.object({ - query: z.string().describe("The search query"), - searchType: z - .enum(["duckduckgo", "opensearch"]) - .describe("The search engine to use"), - }), - execute: async ({ + webSearch: { + description: "Search the web using DuckDuckGo", + parameters: z.object({ + query: z.string().describe("The search query"), + searchType: z + .enum(["duckduckgo", "opensearch"]) + .describe("The search engine to use"), + }), + execute: async ({ + query, + searchType, + }: { + query: string; + searchType: "duckduckgo" | "opensearch"; + }) => { + try { + let results; + if (searchType === "duckduckgo") { + results = await searchDuckDuckGo(query); + } else { + results = await searchOpenSearch(query); + } + + return { + type: "tool-result", + result: { + searchEngine: searchType, query, - searchType, - }: { - query: string; - searchType: "duckduckgo" | "opensearch"; - }) => { - try { - let results; - if (searchType === "duckduckgo") { - results = await searchDuckDuckGo(query); - } else { - results = await searchOpenSearch(query); - } - - return { - type: "tool-result", - result: { - searchEngine: searchType, - query, - results, - timestamp: new Date().toISOString(), - }, - }; - } catch (error) { - return { - type: "tool-result", - result: { - error: "Search failed", - details: - error instanceof Error ? error.message : "Unknown error", - }, - }; - } + results, + timestamp: new Date().toISOString(), }, - }, + }; + } catch (error) { + return { + type: "tool-result", + result: { + error: "Search failed", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; } - : {}), + }, + }, }; export async function POST(request: Request) { @@ -749,11 +743,12 @@ export async function POST(request: Request) { }), }, }, - onFinish: async ({ responseMessages }) => { + onFinish: async (event) => { + const { responseMessages } = event; if (user && user.id) { try { const responseMessagesWithoutIncompleteToolCalls = - sanitizeResponseMessages(responseMessages); + sanitizeResponseMessages(responseMessages as (CoreAssistantMessage | CoreToolMessage)[]); await saveMessages({ chatId: id, diff --git a/app/actions/chat.ts b/app/actions/chat.ts new file mode 100644 index 0000000..3b8eac7 --- /dev/null +++ b/app/actions/chat.ts @@ -0,0 +1,87 @@ +'use server' + +import { Message } from '@/types/chat' +import { customAlphabet } from 'nanoid' +import { Logger } from '@/lib/utils/logger' + +const nanoid = customAlphabet('1234567890abcdef', 10) + +export async function setInput(value: string) { + try { + return value + } catch (error) { + Logger.error('Failed to set input', error) + throw error + } +} + +export async function handleSubmit(formData: FormData) { + try { + const message = formData.get('message')?.toString() || '' + const attachments = JSON.parse(formData.get('attachments')?.toString() || '[]') + + Logger.debug('Handling submit with message', { message, attachments }) + + return { + id: nanoid(), + content: message, + role: 'assistant', + createdAt: new Date(), + attachments, + isIntermediate: true, + status: 'thinking' + } + } catch (error) { + Logger.error('Failed to handle submit', error) + throw error + } +} + +export async function stop() { + try { + return true + } catch (error) { + Logger.error('Failed to stop', error) + throw error + } +} + +export async function setAttachments(value: any[]) { + try { + Logger.debug('Setting attachments', value) + return value + } catch (error) { + Logger.error('Failed to set attachments', error) + throw error + } +} + +export async function setMessages(value: Message[]) { + try { + Logger.debug('Setting messages', value) + return value.map(msg => ({ + ...msg, + isIntermediate: false, + status: msg.status || 'complete' + })) + } catch (error) { + Logger.error('Failed to set messages', error) + throw error + } +} + +export async function append(message: Message) { + try { + Logger.debug('Appending message', message) + return { + ...message, + id: message.id || nanoid(), + createdAt: message.createdAt || new Date(), + isIntermediate: message.isIntermediate || false, + status: message.status || 'complete' + } + } catch (error) { + Logger.error('Failed to append message', error) + throw error + } +} \ No newline at end of file diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..860c2b2 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,151 @@ +import { NextResponse } from "next/server"; +import { rateLimit } from "@/lib/rate-limit"; + +// Initialize rate limiter +const limiter = rateLimit({ + interval: 60 * 1000, // 60 seconds + uniqueTokenPerInterval: 500, +}); + +// Define search providers +const searchProviders = { + async duckduckgo(query: string) { + const response = await fetch( + `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`, + { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Chainable Search Bot/1.0', + }, + next: { revalidate: 3600 } + } + ); + + if (!response.ok) throw new Error('DuckDuckGo search failed'); + + const data = await response.json(); + return { + results: [ + ...(data.Abstract ? [{ + Text: data.Abstract, + FirstURL: data.AbstractURL, + Source: data.AbstractSource, + isAbstract: true, + }] : []), + ...(data.RelatedTopics?.map((topic: any) => ({ + Text: topic.Text, + FirstURL: topic.FirstURL, + Icon: topic.Icon?.URL, + })) || []) + ].filter((result: any) => result.Text && result.Text.trim()), + source: 'DuckDuckGo' + }; + }, + + async serp(query: string) { + const SERP_API_KEY = process.env.SERP_API_KEY; + if (!SERP_API_KEY) throw new Error('SERP API key not configured'); + + const response = await fetch( + `https://serpapi.com/search.json?q=${encodeURIComponent(query)}&api_key=${SERP_API_KEY}`, + { next: { revalidate: 3600 } } + ); + + if (!response.ok) throw new Error('SERP search failed'); + + const data = await response.json(); + return { + results: data.organic_results?.map((result: any) => ({ + Text: result.snippet, + FirstURL: result.link, + Title: result.title, + })) || [], + source: 'SERP' + }; + } +} as const; + +export async function GET(request: Request) { + try { + await limiter.check(request, 10); + + const url = new URL(request.url); + const query = url.searchParams.get("query"); + + if (!query) { + return NextResponse.json({ error: "Query is required" }, { status: 400 }); + } + + // Try each search provider in sequence until one works + let searchError = null; + let results = null; + let source = ''; + + for (const [providerName, provider] of Object.entries(searchProviders)) { + try { + const searchResult = await provider(query); + if (searchResult.results && searchResult.results.length > 0) { + results = searchResult.results; + source = searchResult.source; + break; + } + } catch (error) { + console.error(`${providerName} search error:`, error); + searchError = error instanceof Error ? error : new Error('Unknown error'); + } + } + + // If no results found from any provider + if (!results || results.length === 0) { + // Provide a fallback response with blockchain-specific information + if (query.toLowerCase().includes('blockchain') || query.toLowerCase().includes('crypto')) { + return NextResponse.json({ + results: [{ + Text: "No recent results found. Here are some reliable blockchain resources:\n" + + "- CoinDesk (https://www.coindesk.com)\n" + + "- Cointelegraph (https://cointelegraph.com)\n" + + "- The Block (https://www.theblock.co)\n" + + "- Base Network News (https://base.mirror.xyz)", + FirstURL: "https://base.org", + isAbstract: true + }], + query, + source: 'Fallback', + timestamp: new Date().toISOString(), + }); + } + + return NextResponse.json({ + results: [], + query, + error: "No results found", + message: searchError?.message || "No matching results found", + timestamp: new Date().toISOString(), + }); + } + + return NextResponse.json({ + results: results.slice(0, 5), + query, + source, + timestamp: new Date().toISOString(), + }, { + headers: { + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + }, + }); + + } catch (error) { + console.error("Search error:", error); + const url = new URL(request.url); + return NextResponse.json( + { + error: "Failed to perform search", + message: error instanceof Error ? error.message : "Unknown error", + query: url.searchParams.get("query"), + timestamp: new Date().toISOString(), + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/wallet/balance/route.ts b/app/api/wallet/balance/route.ts new file mode 100644 index 0000000..7816ed7 --- /dev/null +++ b/app/api/wallet/balance/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { createPublicClient, http, formatEther } from "viem"; +import { baseSepolia, base } from "viem/chains"; + +// Initialize public clients for different networks +const clients = { + "base-sepolia": createPublicClient({ + chain: baseSepolia, + transport: http(), + }), + "base": createPublicClient({ + chain: base, + transport: http(), + }), +}; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const address = searchParams.get("address"); + const network = searchParams.get("network") || "base-sepolia"; + + if (!address) { + return NextResponse.json( + { error: "Wallet address is required" }, + { status: 400 } + ); + } + + const client = clients[network as keyof typeof clients]; + if (!client) { + return NextResponse.json( + { error: "Invalid network specified" }, + { status: 400 } + ); + } + + // Get ETH balance + const balance = await client.getBalance({ address: address as `0x${string}` }); + + // Format the balance + const formattedBalance = formatEther(balance); + + return NextResponse.json({ + address, + network, + balance: formattedBalance, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error("Balance fetch error:", error); + return NextResponse.json( + { error: "Failed to fetch wallet balance" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/wallet/debank/route.ts b/app/api/wallet/debank/route.ts new file mode 100644 index 0000000..7f41d9b --- /dev/null +++ b/app/api/wallet/debank/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; + +const DEBANK_API_KEY = process.env.DEBANK_API_KEY; +const DEBANK_BASE_URL = "https://pro-openapi.debank.com/v1"; + +// Helper function to fetch from DeBankAPI +async function fetchFromDeBank(endpoint: string, address: string) { + try { + const response = await fetch(`${DEBANK_BASE_URL}${endpoint}${address}`, { + headers: { + 'Accept': 'application/json', + 'AccessKey': DEBANK_API_KEY || '', + }, + }); + + if (!response.ok) { + throw new Error(`DeBankAPI error: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error('DeBankAPI fetch error:', error); + throw error; + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const address = searchParams.get("address"); + + if (!address) { + return NextResponse.json( + { error: "Wallet address is required" }, + { status: 400 } + ); + } + + if (!DEBANK_API_KEY) { + return NextResponse.json( + { error: "DeBankAPI key not configured" }, + { status: 500 } + ); + } + + // Fetch basic wallet info + const [totalBalance, tokens] = await Promise.all([ + fetchFromDeBank('/user/total_balance?id=', address), + fetchFromDeBank('/user/token_list?id=', address), + ]); + + return NextResponse.json({ + address, + totalBalance: { + total_usd_value: totalBalance?.total_usd_value || 0, + }, + tokens: tokens?.map((token: any) => ({ + chain: token.chain, + symbol: token.symbol, + balance: token.balance, + price: token.price, + usd_value: token.amount * token.price, + })) || [], + timestamp: new Date().toISOString(), + }); + + } catch (error) { + console.error("DeBankAPI error:", error); + return NextResponse.json( + { error: "Failed to fetch wallet data from DeBankAPI" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/wallet/info/route.ts b/app/api/wallet/info/route.ts new file mode 100644 index 0000000..b1052ae --- /dev/null +++ b/app/api/wallet/info/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; +import { createPublicClient, http, formatEther } from "viem"; +import { baseSepolia, base } from "viem/chains"; +import { rateLimit } from "@/lib/rate-limit"; + +const limiter = rateLimit({ + interval: 60 * 1000, + uniqueTokenPerInterval: 500, +}); + +// Initialize public clients for different networks +const clients = { + "base-sepolia": createPublicClient({ + chain: baseSepolia, + transport: http(), + }), + "base": createPublicClient({ + chain: base, + transport: http(), + }), +}; + +async function fetchTokenBalances(address: string) { + const DEBANK_API_KEY = process.env.DEBANK_API_KEY; + const DEBANK_BASE_URL = "https://pro-openapi.debank.com/v1"; + + try { + const response = await fetch( + `${DEBANK_BASE_URL}/user/token_list?id=${address}`, + { + headers: { + 'Accept': 'application/json', + 'AccessKey': DEBANK_API_KEY || '', + }, + } + ); + + if (!response.ok) { + throw new Error('DeBankAPI request failed'); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error('DeBankAPI error:', error); + return []; + } +} + +export async function GET(request: Request) { + try { + await limiter.check(request, 10); + + const { searchParams } = new URL(request.url); + const address = searchParams.get("address"); + const network = searchParams.get("network") || "base-sepolia"; + + if (!address) { + return NextResponse.json( + { error: "Wallet address is required" }, + { status: 400 } + ); + } + + const client = clients[network as keyof typeof clients]; + if (!client) { + return NextResponse.json( + { error: "Invalid network specified" }, + { status: 400 } + ); + } + + // Fetch data in parallel + const [balance, tokens] = await Promise.all([ + client.getBalance({ address: address as `0x${string}` }), + fetchTokenBalances(address), + ]); + + // Format the response + const formattedBalance = formatEther(balance); + const formattedTokens = tokens.map((token: any) => ({ + chain: token.chain, + symbol: token.symbol, + balance: token.balance, + price: token.price, + usd_value: token.amount * token.price, + })); + + return NextResponse.json({ + address, + network, + nativeBalance: { + amount: formattedBalance, + symbol: network.includes('sepolia') ? 'SEP-ETH' : 'ETH', + }, + tokens: formattedTokens, + timestamp: new Date().toISOString(), + }); + + } catch (error) { + console.error("Wallet info error:", error); + return NextResponse.json( + { error: "Failed to fetch wallet information" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/components/app-sidebar-client.tsx b/app/components/app-sidebar-client.tsx new file mode 100644 index 0000000..d728c5f --- /dev/null +++ b/app/components/app-sidebar-client.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { SidebarContent, SidebarHeader, SidebarSeparator } from "@/components/ui/sidebar"; +import { ChatHistory } from "./chat-history"; +import { createClient } from "@/lib/supabase/client"; + +interface User { + id: string; + email?: string; +} + +interface AppSidebarClientProps { + initialUser: User | null; +} + +export function AppSidebarClient({ initialUser }: AppSidebarClientProps) { + const [user, setUser] = useState(initialUser); + + useEffect(() => { + const supabase = createClient(); + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setUser(session?.user || null); + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + return ( + + ); +} \ No newline at end of file diff --git a/app/components/app-sidebar-server.tsx b/app/components/app-sidebar-server.tsx new file mode 100644 index 0000000..87b756d --- /dev/null +++ b/app/components/app-sidebar-server.tsx @@ -0,0 +1,18 @@ +import { headers } from "next/headers"; +import { createClient } from "@/app/lib/supabase/server"; +import { AppSidebarClient } from "./app-sidebar-client"; + +export async function AppSidebarServer() { + // This ensures this component is rendered on the server + headers(); + + const supabase = createClient(); + const { data: { session }, error } = await supabase.auth.getSession(); + + if (error) { + console.error("Error fetching session:", error); + return null; + } + + return ; +} \ No newline at end of file diff --git a/app/components/app-sidebar.tsx b/app/components/app-sidebar.tsx new file mode 100644 index 0000000..3a5858f --- /dev/null +++ b/app/components/app-sidebar.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SidebarContent, SidebarHeader, SidebarSeparator } from "@/components/ui/sidebar"; +import { ChatHistory } from "./chat-history"; +import { createClient } from "@/lib/supabase/client"; + +interface User { + id: string; + email?: string; +} + +export function AppSidebar() { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadUser = async () => { + try { + const supabase = createClient(); + const { data: { session } } = await supabase.auth.getSession(); + if (session?.user) { + setUser(session.user); + } + } catch (error) { + console.error("Error loading user:", error); + } finally { + setIsLoading(false); + } + }; + loadUser(); + }, []); + + return ( + + ); +} \ No newline at end of file diff --git a/app/components/chat-history-client.tsx b/app/components/chat-history-client.tsx new file mode 100644 index 0000000..f2b36c7 --- /dev/null +++ b/app/components/chat-history-client.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { memo } from "react"; +import useSWR from "swr"; + +import { useSidebar } from "@/components/ui/sidebar"; +import { Chat } from "@/lib/supabase/types"; +import { GroupedChatList } from "./chat-history-grouped-list"; + +export function ChatHistoryClient({ + initialChats, + userId, +}: { + initialChats: Chat[]; + userId: string; +}) { + const { id } = useParams() as { id: string }; + const { setOpenMobile } = useSidebar(); + + const { data: chats = initialChats } = useSWR( + `/api/chats?userId=${userId}`, + null, + { + fallbackData: initialChats, + revalidateOnFocus: false + } + ); + + return ( + setOpenMobile(false)} + /> + ); +} \ No newline at end of file diff --git a/app/components/chat-history-grouped-list.tsx b/app/components/chat-history-grouped-list.tsx new file mode 100644 index 0000000..d29bf22 --- /dev/null +++ b/app/components/chat-history-grouped-list.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { isToday, isYesterday, subMonths, subWeeks } from "date-fns"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { memo } from "react"; +import { toast } from "sonner"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { Chat } from "@/lib/supabase/types"; +import { createClient } from "@/lib/supabase/client"; + +interface GroupedChats { + today: Chat[]; + yesterday: Chat[]; + lastWeek: Chat[]; + lastMonth: Chat[]; + older: Chat[]; +} + +interface GroupedChatListProps { + chats: Chat[]; + userId: string; + selectedChatId?: string; + onChatClick?: () => void; +} + +export const GroupedChatList = memo(function GroupedChatList({ + chats, + userId, + selectedChatId, + onChatClick, +}: GroupedChatListProps) { + const router = useRouter(); + + const groupedChats = chats.reduce( + (groups, chat) => { + const date = new Date(chat.created_at); + if (isToday(date)) { + groups.today.push(chat); + } else if (isYesterday(date)) { + groups.yesterday.push(chat); + } else if (date > subWeeks(new Date(), 1)) { + groups.lastWeek.push(chat); + } else if (date > subMonths(new Date(), 1)) { + groups.lastMonth.push(chat); + } else { + groups.older.push(chat); + } + return groups; + }, + { + today: [] as Chat[], + yesterday: [] as Chat[], + lastWeek: [] as Chat[], + lastMonth: [] as Chat[], + older: [] as Chat[], + } as GroupedChats + ); + + const handleDeleteChat = async (chatId: string) => { + try { + const supabase = createClient(); + const { error } = await supabase.from("chats").delete().eq("id", chatId); + if (error) throw error; + toast.success("Chat deleted"); + router.refresh(); + } catch (error) { + toast.error("Failed to delete chat"); + } + }; + + const renderChatGroup = (title: string, chats: Chat[]) => { + if (!chats.length) return null; + + return ( + +
{title}
+ + + {chats.map((chat) => ( + + + {chat.title || "New Chat"} + + + + + + + handleDeleteChat(chat.id)}> + Delete Chat + + + + + ))} + + +
+ ); + }; + + return ( + <> + {renderChatGroup("Today", groupedChats.today)} + {renderChatGroup("Yesterday", groupedChats.yesterday)} + {renderChatGroup("Last 7 Days", groupedChats.lastWeek)} + {renderChatGroup("Last 30 Days", groupedChats.lastMonth)} + {renderChatGroup("Older", groupedChats.older)} + + ); +}); \ No newline at end of file diff --git a/app/components/chat-history-list.tsx b/app/components/chat-history-list.tsx new file mode 100644 index 0000000..66ad2b3 --- /dev/null +++ b/app/components/chat-history-list.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { isToday, isYesterday, subMonths, subWeeks } from "date-fns"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { memo } from "react"; +import { toast } from "sonner"; +import { createClient } from "@/lib/supabase/client"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +interface Chat { + id: string; + title: string; + created_at: string; +} + +interface GroupedChats { + today: Chat[]; + yesterday: Chat[]; + lastWeek: Chat[]; + lastMonth: Chat[]; + older: Chat[]; +} + +interface ChatHistoryListProps { + chats: Chat[]; +} + +export const ChatHistoryList = memo(function ChatHistoryList({ chats }: ChatHistoryListProps) { + const { id: selectedChatId } = useParams() as { id?: string }; + + const groupedChats = chats.reduce( + (groups, chat) => { + const date = new Date(chat.created_at); + if (isToday(date)) { + groups.today.push(chat); + } else if (isYesterday(date)) { + groups.yesterday.push(chat); + } else if (date > subWeeks(new Date(), 1)) { + groups.lastWeek.push(chat); + } else if (date > subMonths(new Date(), 1)) { + groups.lastMonth.push(chat); + } else { + groups.older.push(chat); + } + return groups; + }, + { + today: [] as Chat[], + yesterday: [] as Chat[], + lastWeek: [] as Chat[], + lastMonth: [] as Chat[], + older: [] as Chat[], + } as GroupedChats + ); + + const handleDeleteChat = async (chatId: string) => { + try { + const supabase = createClient(); + const { error } = await supabase.from("chats").delete().eq("id", chatId); + if (error) throw error; + toast.success("Chat deleted"); + } catch (error) { + console.error("Failed to delete chat:", error); + toast.error("Failed to delete chat"); + } + }; + + const renderChatGroup = (title: string, chats: Chat[]) => { + if (!chats.length) return null; + + return ( + +
{title}
+ + + {chats.map((chat) => ( + + + {chat.title || "New Chat"} + + + ))} + + +
+ ); + }; + + return ( + <> + {renderChatGroup("Today", groupedChats.today)} + {renderChatGroup("Yesterday", groupedChats.yesterday)} + {renderChatGroup("Last 7 Days", groupedChats.lastWeek)} + {renderChatGroup("Last 30 Days", groupedChats.lastMonth)} + {renderChatGroup("Older", groupedChats.older)} + + ); +}); \ No newline at end of file diff --git a/app/components/chat-history-server.tsx b/app/components/chat-history-server.tsx new file mode 100644 index 0000000..edb193c --- /dev/null +++ b/app/components/chat-history-server.tsx @@ -0,0 +1,22 @@ +import { headers } from "next/headers"; +import { createClient } from "@/app/lib/supabase/server"; +import { ChatHistoryClient } from "./chat-history-client"; + +export async function ChatHistoryServer({ userId }: { userId: string }) { + // This ensures this component is rendered on the server + headers(); + + const supabase = createClient(); + const { data: chats, error } = await supabase + .from("chats") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }); + + if (error) { + console.error("Error fetching chats:", error); + return null; + } + + return ; +} \ No newline at end of file diff --git a/app/components/chat-history-skeleton.tsx b/app/components/chat-history-skeleton.tsx new file mode 100644 index 0000000..979d574 --- /dev/null +++ b/app/components/chat-history-skeleton.tsx @@ -0,0 +1,32 @@ +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export function ChatHistorySkeleton() { + return ( + +
Today
+ + + {[44, 32, 28, 64, 52].map((width, index) => ( + +
+
+
+ + ))} + + + + ); +} \ No newline at end of file diff --git a/app/components/chat-history.tsx b/app/components/chat-history.tsx new file mode 100644 index 0000000..344bc16 --- /dev/null +++ b/app/components/chat-history.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { createClient } from "@/lib/supabase/client"; +import { ChatHistoryList } from "./chat-history-list"; + +interface ChatHistoryProps { + userId: string; +} + +async function fetchChats(userId: string) { + const supabase = createClient(); + const { data, error } = await supabase + .from("chats") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }); + + if (error) throw error; + return data; +} + +export function ChatHistory({ userId }: ChatHistoryProps) { + const { data: chats = [], isLoading } = useQuery({ + queryKey: ["chats", userId], + queryFn: () => fetchChats(userId), + }); + + useEffect(() => { + const supabase = createClient(); + + const channel = supabase + .channel("chats") + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "chats", + filter: `user_id=eq.${userId}`, + }, + () => { + // Invalidate and refetch + void fetchChats(userId); + } + ) + .subscribe(); + + return () => { + void supabase.removeChannel(channel); + }; + }, [userId]); + + if (isLoading) { + return
Loading...
; + } + + return ; +} \ No newline at end of file diff --git a/app/components/chat-layout-client.tsx b/app/components/chat-layout-client.tsx new file mode 100644 index 0000000..81176c2 --- /dev/null +++ b/app/components/chat-layout-client.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { SidebarInset } from "@/components/ui/sidebar"; + +interface ChatLayoutClientProps { + children: React.ReactNode; +} + +export function ChatLayoutClient({ children }: ChatLayoutClientProps) { + return ( +
+ {children} +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/app/components/chat-layout.tsx b/app/components/chat-layout.tsx new file mode 100644 index 0000000..afa04e7 --- /dev/null +++ b/app/components/chat-layout.tsx @@ -0,0 +1,30 @@ +import { headers } from "next/headers"; +import { createClient } from "@/app/lib/supabase/server"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { ChatLayoutClient } from "./chat-layout-client"; + +interface ChatLayoutProps { + children: React.ReactNode; + defaultOpen?: boolean; +} + +export async function ChatLayout({ children, defaultOpen = true }: ChatLayoutProps) { + // This ensures this component is rendered on the server + headers(); + + const supabase = createClient(); + const { data: { session }, error } = await supabase.auth.getSession(); + + if (error) { + console.error("Error fetching session:", error); + return null; + } + + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/app/components/chat.tsx b/app/components/chat.tsx new file mode 100644 index 0000000..b6c7d75 --- /dev/null +++ b/app/components/chat.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useChat } from "ai/react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useWindowSize } from "usehooks-ts"; + +import { ChatProps, Message } from "@/types/chat"; +import { ChatHeader } from "./chat-header"; +import { ChatInput } from "./chat-input"; +import { ChatScrollAnchor } from "./chat-scroll-anchor"; +import { PreviewMessage } from "./preview-message"; +import { IntermediateMessage } from "./intermediate-message"; +import { Logger } from "@/lib/utils/logger"; + +export function Chat({ id, initialMessages = [], append: appendToChat }: ChatProps) { + const { height } = useWindowSize(); + const [intermediateMessage, setIntermediateMessage] = useState(null); + + const { + messages, + input, + handleInputChange, + handleSubmit, + isLoading, + append, + setMessages, + } = useChat({ + id, + initialMessages, + api: "/api/chat", + onResponse: (response) => { + if (response.status === 401) { + toast.error( + "You have reached your daily message limit. Please try again tomorrow." + ); + } + }, + onFinish: () => { + setIntermediateMessage(null); + }, + onError: (error) => { + Logger.error("Chat error", error); + toast.error("An error occurred. Please try again."); + setIntermediateMessage(null); + }, + }); + + const appendMessage = async (message: Message) => { + if (message.isIntermediate) { + setIntermediateMessage(message); + return null; + } + + if (appendToChat) { + return appendToChat(message); + } + + return append(message); + }; + + return ( +
+ +
+
+ + {messages.map((message) => ( + + ))} + + {intermediateMessage && ( + + )} + + + +
+
+ +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/intermediate-message.tsx b/app/components/intermediate-message.tsx new file mode 100644 index 0000000..10f9489 --- /dev/null +++ b/app/components/intermediate-message.tsx @@ -0,0 +1,43 @@ +import { motion } from 'framer-motion' +import { Message } from '@/types/chat' +import { cn } from '@/lib/utils' + +interface IntermediateMessageProps { + message: Message +} + +export function IntermediateMessage({ message }: IntermediateMessageProps) { + return ( + +
+
+ {message.status === 'thinking' && ( +
+
+
+
+
+ )} + {message.status === 'processing' && ( +
+
+ Processing... +
+ )} +

+ {message.content} +

+
+
+ + ) +} \ No newline at end of file diff --git a/app/components/message.tsx b/app/components/message.tsx new file mode 100644 index 0000000..d3d5bac --- /dev/null +++ b/app/components/message.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Logger } from "@/lib/utils/logger"; +import { ThumbsDown, ThumbsUp } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { MessageProps, VoteType, ChatMessage } from "@/types/message"; + +export function Message({ + message, + chatId, + isLoading, + vote +}: MessageProps) { + const [isVoting, setIsVoting] = useState(false); + const isUser = message.role === "user"; + + const handleVote = async (type: VoteType) => { + if (!chatId || isVoting) return; + setIsVoting(true); + try { + const response = await fetch("/api/chat/vote", { + method: "POST", + body: JSON.stringify({ chatId, messageId: message.id, type }), + }); + if (!response.ok) throw new Error("Failed to vote"); + toast.success("Vote recorded"); + } catch (error) { + Logger.error("Failed to vote", error); + toast.error("Failed to record vote"); + } finally { + setIsVoting(false); + } + }; + + return ( +
+
+
+ {message.content} +
+
+ {!isUser && !isLoading && ( +
+ + +
+ )} +
+ ); +} + +export function PreviewMessage({ message }: { message: ChatMessage }) { + const isUser = message.role === "user"; + return ( +
+
+
+ {message.content} +
+
+
+ ); +} + +export function ThinkingMessage() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/root-layout-client.tsx b/app/components/root-layout-client.tsx new file mode 100644 index 0000000..dfdfbc9 --- /dev/null +++ b/app/components/root-layout-client.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { createClient } from "@/lib/supabase/client"; +import { Sidebar } from "./sidebar"; + +interface User { + id: string; + email?: string; +} + +interface RootLayoutClientProps { + children: React.ReactNode; + user: User | null; +} + +export function RootLayoutClient({ children, user: initialUser }: RootLayoutClientProps) { + const [user, setUser] = useState(initialUser); + + useEffect(() => { + const supabase = createClient(); + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setUser(session?.user || null); + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + return ( +
+ +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx new file mode 100644 index 0000000..765b933 --- /dev/null +++ b/app/components/sidebar.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { SidebarContent, SidebarHeader, SidebarSeparator } from "@/components/ui/sidebar"; +import { ChatHistory } from "./chat-history"; + +interface User { + id: string; + email?: string; +} + +interface SidebarProps { + user: User | null; +} + +export function Sidebar({ user }: SidebarProps) { + return ( + + ); +} \ No newline at end of file diff --git a/app/db/queries.client.ts b/app/db/queries.client.ts new file mode 100644 index 0000000..7ddb5f2 --- /dev/null +++ b/app/db/queries.client.ts @@ -0,0 +1,37 @@ +import { createClient } from "@/lib/supabase/client"; + +export const getUserById = async (userId: string) => { + const supabase = createClient(); + const { data, error } = await supabase + .from("users") + .select("*") + .eq("id", userId) + .single(); + + if (error) throw error; + return data; +}; + +export const getChatById = async (chatId: string) => { + const supabase = createClient(); + const { data, error } = await supabase + .from("chats") + .select("*") + .eq("id", chatId) + .single(); + + if (error) throw error; + return data; +}; + +export const getMessagesByChatId = async (chatId: string) => { + const supabase = createClient(); + const { data, error } = await supabase + .from("messages") + .select("*") + .eq("chat_id", chatId) + .order("created_at", { ascending: true }); + + if (error) throw error; + return data; +}; \ No newline at end of file diff --git a/app/db/queries.server.ts b/app/db/queries.server.ts new file mode 100644 index 0000000..15a91d3 --- /dev/null +++ b/app/db/queries.server.ts @@ -0,0 +1,43 @@ +import "server-only"; +import { cache } from "react"; +import { unstable_cache } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; + +// Cache duration +const CACHE_DURATION = 60 * 60; // 1 hour + +export const getCachedUserById = cache(async (userId: string) => { + const supabase = createClient(); + const { data, error } = await supabase + .from("users") + .select("*") + .eq("id", userId) + .single(); + + if (error) throw error; + return data; +}); + +export const getCachedChatById = cache(async (chatId: string) => { + const supabase = createClient(); + const { data, error } = await supabase + .from("chats") + .select("*") + .eq("id", chatId) + .single(); + + if (error) throw error; + return data; +}); + +export const getCachedMessagesByChatId = cache(async (chatId: string) => { + const supabase = createClient(); + const { data, error } = await supabase + .from("messages") + .select("*") + .eq("chat_id", chatId) + .order("created_at", { ascending: true }); + + if (error) throw error; + return data; +}); \ No newline at end of file diff --git a/app/lib/supabase/server.ts b/app/lib/supabase/server.ts new file mode 100644 index 0000000..1ba69dd --- /dev/null +++ b/app/lib/supabase/server.ts @@ -0,0 +1,25 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { Database } from "@/lib/supabase/types"; + +export const createClient = () => { + const cookieStore = cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return cookieStore.get(name)?.value; + }, + set(name: string, value: string, options: { path: string; maxAge?: number }) { + cookieStore.set(name, value, options); + }, + remove(name: string, options: { path: string }) { + cookieStore.set(name, "", { ...options, maxAge: -1 }); + }, + }, + } + ); +}; \ No newline at end of file diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index d7d72f6..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/components/custom/__tests__/multimodal-input.test.tsx b/components/custom/__tests__/multimodal-input.test.tsx index 98149ea..2169c1f 100644 --- a/components/custom/__tests__/multimodal-input.test.tsx +++ b/components/custom/__tests__/multimodal-input.test.tsx @@ -45,17 +45,26 @@ describe("MultimodalInput", () => { ); }); - it("should display an error message on failed upload", async () => { - render(); - const fileInput = screen.getByLabelText(/upload/i); - const file = new File(["content"], "test.png", { type: "image/png" }); + it("should trigger web search on button click", async () => { + render(); + const webSearchButton = screen.getByRole("button", { name: /web search/i }); - // Mock fetch to simulate a failed upload - global.fetch = vi.fn(() => Promise.resolve({ ok: false })) as jest.Mock; + // Mock fetch to simulate a successful search + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ results: "Sample search results" }), + }), + ) as jest.Mock; - fireEvent.change(fileInput, { target: { files: [file] } }); + fireEvent.click(webSearchButton); - // Check for error message - await screen.findByText("Failed to upload one or more files"); + // Check if append was called with search results + await screen.findByText(/web search results for: blockchain news/i); + expect(defaultProps.append).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("Sample search results"), + }), + ); }); }); diff --git a/components/custom/action-message.tsx b/components/custom/action-message.tsx new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/components/custom/action-message.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/custom/app-sidebar.tsx b/components/custom/app-sidebar.tsx index 0250a3c..086b91e 100644 --- a/components/custom/app-sidebar.tsx +++ b/components/custom/app-sidebar.tsx @@ -17,7 +17,7 @@ import { SidebarMenu, useSidebar, } from "@/components/ui/sidebar"; -import { BetterTooltip } from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; export function AppSidebar({ user }: { user: User | null }) { const router = useRouter(); @@ -40,19 +40,22 @@ export function AppSidebar({ user }: { user: User | null }) { Chatbot
- - - + + + + + New Chat +
diff --git a/components/custom/block.tsx b/components/custom/block.tsx index 89bca7c..6157abd 100644 --- a/components/custom/block.tsx +++ b/components/custom/block.tsx @@ -32,9 +32,11 @@ import { Toolbar } from "./toolbar"; import { useScrollToBottom } from "./use-scroll-to-bottom"; import { VersionFooter } from "./version-footer"; import { Button } from "../ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, BetterTooltip, TooltipTrigger } from "../ui/tooltip"; import type { Document, Suggestion, Vote } from "@/lib/supabase/types"; +import type { MultimodalInputProps } from "@/types/chat"; +import type { AIAttachment } from "@/types/attachments"; export interface UIBlock { title: string; @@ -123,6 +125,14 @@ const useDocumentContent = ( }, [documents, index]); }; +interface BlockProps extends Omit { + block: UIBlock; + setBlock: React.Dispatch>; + votes?: Array; + attachments: AIAttachment[]; + setAttachments: React.Dispatch>; +} + export function Block({ chatId, input, @@ -138,30 +148,7 @@ export function Block({ messages, setMessages, votes, -}: { - chatId: string; - input: string; - setInput: (input: string) => void; - isLoading: boolean; - stop: () => void; - attachments: Array; - setAttachments: Dispatch>>; - block: UIBlock; - setBlock: Dispatch>; - messages: Array; - setMessages: Dispatch>>; - votes: Array | undefined; - append: ( - message: Message | CreateMessage, - chatRequestOptions?: ChatRequestOptions, - ) => Promise; - handleSubmit: ( - event?: { - preventDefault?: () => void; - }, - chatRequestOptions?: ChatRequestOptions, - ) => void; -}) { +}: BlockProps) { const [messagesContainerRef, messagesEndRef] = useScrollToBottom(); @@ -455,7 +442,7 @@ export function Block({ ref={messagesContainerRef} className="flex flex-col gap-4 h-full items-center overflow-y-scroll px-4 pt-20" > - {messages.map((message, index) => ( + {messages.map((message: Message, index: number) => ( - Copy to clipboard + @@ -644,14 +631,7 @@ export function Block({ - - - {documents?.length - ? `${currentVersionIndex} / ${documents.length}` - : ""} - {" "} -

View Previous version

-
+
@@ -666,7 +646,7 @@ export function Block({ - View Next version + @@ -702,12 +682,7 @@ export function Block({ /> - - - {mode === "diff" ? "diff mode" : "edit mode"} - {" "} -

Toggle mode to {mode === "diff" ? "edit" : "diff"}

-
+
diff --git a/components/custom/chat-history-client.tsx b/components/custom/chat-history-client.tsx index 9acdd52..a876043 100644 --- a/components/custom/chat-history-client.tsx +++ b/components/custom/chat-history-client.tsx @@ -16,7 +16,7 @@ export function ChatHistoryClient({ initialChats: Chat[]; userId: string; }) { - const { id } = useParams(); + const { id } = useParams() as { id: string }; const { setOpenMobile } = useSidebar(); // Use SWR with initial data from server diff --git a/components/custom/chat.tsx b/components/custom/chat.tsx index 07ccaa5..8f7316a 100644 --- a/components/custom/chat.tsx +++ b/components/custom/chat.tsx @@ -1,15 +1,22 @@ "use client"; import { useChat } from "ai/react"; -import type { Message, Attachment } from "ai"; -import { AnimatePresence } from "framer-motion"; -import { KeyboardIcon } from "lucide-react"; -import { useState, useEffect, type ClipboardEvent } from "react"; +import type { Message, CreateMessage, ChatRequestOptions } from "ai"; +import { AnimatePresence, motion } from "framer-motion"; +import { KeyboardIcon, WalletIcon } from "lucide-react"; +import { useState, Suspense } from "react"; import useSWR, { useSWRConfig } from "swr"; import { useWindowSize } from "usehooks-ts"; import { Progress } from "@/components/ui/progress"; import { toast } from "sonner"; +import type { + ChatProps, + FileUploadState, + AppendFunction, + AppendOptions +} from "@/types/chat"; +import type { Attachment, AIAttachment } from "@/types/attachments"; import { Block, UIBlock } from "@/components/custom/block"; import { BlockStreamHandler } from "@/components/custom/block-stream-handler"; import { ChatHeader } from "@/components/custom/chat-header"; @@ -17,256 +24,273 @@ import { MultimodalInput } from "@/components/custom/multimodal-input"; import { Overview } from "@/components/custom/overview"; import { PreviewMessage, ThinkingMessage } from "@/components/custom/message"; import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom"; -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, BetterTooltip, TooltipTrigger } from "../ui/tooltip"; import { Database } from "@/lib/supabase/types"; import { fetcher } from "@/lib/utils"; +import { useWalletState } from "@/hooks/useWalletState"; +import { containerAnimationVariants } from "@/lib/animation-variants"; +import { LoadingPage } from "./loading-page"; type Vote = Database["public"]["Tables"]["votes"]["Row"]; -interface FileUploadState { - progress: number; - uploading: boolean; - error: string | null; -} - -export function Chat({ - id, - initialMessages, - selectedModelId, -}: { - id: string; - initialMessages: Array; - selectedModelId: string; -}) { - const { mutate } = useSWRConfig(); - const { width: windowWidth = 1920, height: windowHeight = 1080 } = - useWindowSize(); +export function Chat({ id, initialMessages, selectedModelId }: ChatProps) { + const { mutate } = useSWRConfig(); + const { width: windowWidth = 1920, height: windowHeight = 1080 } = useWindowSize(); + const [messagesContainerRef, messagesEndRef] = useScrollToBottom(); + const [attachments, setAttachments] = useState([]); + const [fileUpload, setFileUpload] = useState({ + progress: 0, + uploading: false, + error: null, + }); + const [webSearchEnabled] = useState(true); + const [block, setBlock] = useState({ + documentId: "init", + content: "", + title: "", + status: "idle", + isVisible: false, + boundingBox: { + top: windowHeight / 4, + left: windowWidth / 4, + width: 250, + height: 50, + }, + }); - const { - messages, - setMessages, - handleSubmit, - input, - setInput, - append, - isLoading, - stop, - data: streamingData, - } = useChat({ - body: { id, modelId: selectedModelId }, - initialMessages, - onFinish: () => { - mutate("/api/history"); - }, - }); + const { + messages, + setMessages, + handleSubmit, + input, + setInput, + append: rawAppend, + isLoading, + stop, + data: streamingData, + } = useChat({ + body: { id, modelId: selectedModelId }, + initialMessages, + onFinish: () => { + mutate("/api/history"); + }, + }); - const [block, setBlock] = useState({ - documentId: "init", - content: "", - title: "", - status: "idle", - isVisible: false, - boundingBox: { - top: windowHeight / 4, - left: windowWidth / 4, - width: 250, - height: 50, - }, - }); + const { data: votes, isLoading: isVotesLoading } = useSWR>( + `/api/vote?chatId=${id}`, + fetcher + ); - const { data: votes } = useSWR>( - `/api/vote?chatId=${id}`, - fetcher, - ); + if (isVotesLoading) { + return ; + } - const [messagesContainerRef, messagesEndRef] = - useScrollToBottom(); - const [attachments, setAttachments] = useState>([]); - const [fileUpload, setFileUpload] = useState({ - progress: 0, - uploading: false, - error: null, - }); + const append = async ( + message: CreateMessage, + options?: ChatRequestOptions + ) => { + const messageWithId = { + id: crypto.randomUUID(), + ...message, + }; + + const result = await rawAppend(messageWithId, { + ...options, + experimental_attachments: options?.experimental_attachments as AIAttachment[] + }); + return result || null; + }; - const handleFileUpload = async (file: File) => { - if (!file) return; + const handleFileUpload = async (file: File) => { + if (!file) return; - if (file.size > 10 * 1024 * 1024) { - toast.error("File size must be less than 10MB"); - return; - } + if (file.size > 10 * 1024 * 1024) { + toast.error("File size must be less than 10MB"); + return; + } - setFileUpload({ progress: 0, uploading: true, error: null }); + setFileUpload({ progress: 0, uploading: true, error: null }); - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - const formData = new FormData(); - formData.append("file", file); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append("file", file); - xhr.upload.addEventListener("progress", (e) => { - if (e.lengthComputable) { - const progress = Math.round((e.loaded * 100) / e.total); - setFileUpload((prev) => ({ ...prev, progress })); - } - }); + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + const progress = Math.round((e.loaded * 100) / e.total); + setFileUpload((prev) => ({ ...prev, progress })); + } + }); - xhr.addEventListener("load", () => { - if (xhr.status === 200) { - const response = JSON.parse(xhr.responseText); - toast.success("File uploaded successfully"); - append({ - role: "user", - content: `[File uploaded: ${file.name}](${response.url})`, - }); - resolve(response); - } else { - setFileUpload((prev) => ({ - ...prev, - error: "Upload failed", - })); - toast.error("Failed to upload file"); - reject(new Error("Upload failed")); - } - setFileUpload((prev) => ({ ...prev, uploading: false })); - }); + xhr.addEventListener("load", () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + toast.success("File uploaded successfully"); + append({ + id: crypto.randomUUID(), + role: "user", + content: `[File uploaded: ${file.name}](${response.url})`, + }); + resolve(response); + } else { + setFileUpload((prev) => ({ + ...prev, + error: "Upload failed", + })); + toast.error("Failed to upload file"); + reject(new Error("Upload failed")); + } + setFileUpload((prev) => ({ ...prev, uploading: false })); + }); - xhr.addEventListener("error", () => { - setFileUpload((prev) => ({ - ...prev, - error: "Upload failed", - uploading: false, - })); - toast.error("Failed to upload file"); - reject(new Error("Upload failed")); - }); + xhr.addEventListener("error", () => { + setFileUpload((prev) => ({ + ...prev, + error: "Upload failed", + uploading: false, + })); + toast.error("Failed to upload file"); + reject(new Error("Upload failed")); + }); - xhr.open("POST", "/api/upload"); - xhr.send(formData); - }); - }; + xhr.open("POST", "/api/upload"); + xhr.send(formData); + }); + }; - return ( - <> -
- -
- {messages.length === 0 && } + return ( + <> +
+ + }> + + + {messages.length === 0 && } - {messages.map((message, index) => ( - vote.message_id === message.id)} - /> - ))} + {messages.map((message, index) => ( + vote.message_id === message.id)} + /> + ))} - {isLoading && - messages.length > 0 && - messages[messages.length - 1].role === "user" && ( - - )} + {isLoading && messages.length > 0 && messages[messages.length - 1].role === "user" && ( + + )} + -
-
+ + +
-
{ - e.preventDefault(); - handleSubmit(e); - }} - aria-label="Chat input form" - > - - -
+ { + e.preventDefault(); + handleSubmit(e); + }} + aria-label="Chat input form" + > + + +
- - {block && block.isVisible && ( - - )} - + + {block && block.isVisible && ( + + )} + - + -
- - - - - -
-

⌘ / to focus input

-

⌘ K to clear chat

-

ESC to stop generation

-
-
-
-
+
+ + + + + +
+

⌘ / to focus input

+

⌘ K to clear chat

+

ESC to stop generation

+
+
+
+
- {fileUpload.uploading && ( -
- -

- Uploading... {fileUpload.progress}% -

-
- )} + {fileUpload.uploading && ( +
+ +

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

+
+ )} - { - const file = e.target.files?.[0]; - if (file) handleFileUpload(file); - }} - className="hidden" - id="file-upload" - accept="image/*,.pdf,.doc,.docx,.txt" - /> - - ); + { + const file = e.target.files?.[0]; + if (file) handleFileUpload(file); + }} + className="hidden" + id="file-upload" + accept="image/*,.pdf,.doc,.docx,.txt" + /> + + ); } diff --git a/components/custom/debug-info.tsx b/components/custom/debug-info.tsx index 7288a64..b86a795 100644 --- a/components/custom/debug-info.tsx +++ b/components/custom/debug-info.tsx @@ -23,6 +23,13 @@ export function DebugInfo() { const [show, setShow] = useState(false); const { width, height } = useWindowSize(); + const memoryInfo = typeof performance !== 'undefined' && + // @ts-ignore - Chrome-specific API + performance.memory ? + // @ts-ignore - Chrome-specific API + `${(performance.memory.usedJSHeapSize || 0) / 1024 / 1024}MB` : + 'N/A'; + const debugInfo: DebugInfo = { buildNumber: process.env.NEXT_PUBLIC_BUILD_NUMBER || "dev", version: process.env.NEXT_PUBLIC_VERSION || "0.0.1", @@ -34,10 +41,7 @@ export function DebugInfo() { screen: `${width}x${height}`, userAgent: typeof window !== "undefined" ? window.navigator.userAgent : "", - memory: - typeof window !== "undefined" - ? `${(performance?.memory?.usedJSHeapSize || 0) / 1024 / 1024}MB` - : "", + memory: memoryInfo, cpu: typeof window !== "undefined" ? navigator?.hardwareConcurrency?.toString() || "" diff --git a/components/custom/icons/DeltaIcon.tsx b/components/custom/icons/DeltaIcon.tsx new file mode 100644 index 0000000..fba1c20 --- /dev/null +++ b/components/custom/icons/DeltaIcon.tsx @@ -0,0 +1,22 @@ +import { SVGProps } from "react"; + +export function DeltaIcon({ + size = 24, + ...props +}: SVGProps & { size?: number }) { + return ( + + + + ); +} \ No newline at end of file diff --git a/components/custom/icons/GlobeIcon.tsx b/components/custom/icons/GlobeIcon.tsx new file mode 100644 index 0000000..ecc07d8 --- /dev/null +++ b/components/custom/icons/GlobeIcon.tsx @@ -0,0 +1,24 @@ +import { SVGProps } from "react"; + +export default function GlobeIcon({ + size = 24, + ...props +}: SVGProps & { size?: number }) { + return ( + + + + + + ); +} diff --git a/components/custom/icons/RedoIcon.tsx b/components/custom/icons/RedoIcon.tsx new file mode 100644 index 0000000..2bfe733 --- /dev/null +++ b/components/custom/icons/RedoIcon.tsx @@ -0,0 +1,23 @@ +import { SVGProps } from "react"; + +export function RedoIcon({ + size = 24, + ...props +}: SVGProps & { size?: number }) { + return ( + + + + + ); +} \ No newline at end of file diff --git a/components/custom/icons/UndoIcon.tsx b/components/custom/icons/UndoIcon.tsx new file mode 100644 index 0000000..cd02d5d --- /dev/null +++ b/components/custom/icons/UndoIcon.tsx @@ -0,0 +1,23 @@ +import { SVGProps } from "react"; + +export function UndoIcon({ + size = 24, + ...props +}: SVGProps & { size?: number }) { + return ( + + + + + ); +} \ No newline at end of file diff --git a/components/custom/icons/WalletIcon.tsx b/components/custom/icons/WalletIcon.tsx new file mode 100644 index 0000000..c418c23 --- /dev/null +++ b/components/custom/icons/WalletIcon.tsx @@ -0,0 +1,24 @@ +import { SVGProps } from "react"; + +export default function WalletIcon({ + size = 24, + ...props +}: SVGProps & { size?: number }) { + return ( + + + + + + ); +} \ No newline at end of file diff --git a/components/custom/icons/index.ts b/components/custom/icons/index.ts new file mode 100644 index 0000000..6b7a074 --- /dev/null +++ b/components/custom/icons/index.ts @@ -0,0 +1,34 @@ +import { + IoArrowUpOutline, + IoAttachOutline, + IoStopOutline, + IoCloseOutline, + IoCopyOutline, + IoGlobeOutline, + IoWalletOutline, + IoSquareOutline, + IoArrowUndoOutline, + IoArrowRedoOutline, + IoTriangleOutline, +} from "react-icons/io5"; +import type { IconType } from "react-icons"; + +// Export icon types +export interface IconProps { + size?: number; + className?: string; + 'aria-label'?: string; +} + +// Export all icons with consistent naming and types +export const ArrowUpIcon: IconType = IoArrowUpOutline; +export const PaperclipIcon: IconType = IoAttachOutline; +export const StopIcon: IconType = IoStopOutline; +export const CrossIcon: IconType = IoCloseOutline; +export const CopyIcon: IconType = IoCopyOutline; +export const GlobeIcon: IconType = IoGlobeOutline; +export const WalletIcon: IconType = IoWalletOutline; +export const SquareIcon: IconType = IoSquareOutline; +export const UndoIcon: IconType = IoArrowUndoOutline; +export const RedoIcon: IconType = IoArrowRedoOutline; +export const DeltaIcon: IconType = IoTriangleOutline; \ No newline at end of file diff --git a/components/custom/intermediate-message.tsx b/components/custom/intermediate-message.tsx new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/components/custom/intermediate-message.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/custom/loading-page.tsx b/components/custom/loading-page.tsx new file mode 100644 index 0000000..4872da4 --- /dev/null +++ b/components/custom/loading-page.tsx @@ -0,0 +1,49 @@ +import { motion } from "framer-motion"; +import { LoadingSkeleton } from "./loading-skeleton"; + +export function LoadingPage() { + return ( + + {/* Header Skeleton */} +
+ +
+ + {/* Content Area */} +
+ {/* Message Skeletons */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ + {/* Input Area Skeleton */} +
+
+ +
+
+
+ + {/* Loading Indicator */} +
+
+
+
+
+ + ); +} \ No newline at end of file diff --git a/components/custom/loading-skeleton.tsx b/components/custom/loading-skeleton.tsx new file mode 100644 index 0000000..2187a74 --- /dev/null +++ b/components/custom/loading-skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; + +interface LoadingSkeletonProps { + className?: string; +} + +export function LoadingSkeleton({ className }: LoadingSkeletonProps) { + return ( +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/custom/message-actions.tsx b/components/custom/message-actions.tsx index 522da68..a961e7c 100644 --- a/components/custom/message-actions.tsx +++ b/components/custom/message-actions.tsx @@ -10,7 +10,7 @@ import { CopyIcon, ThumbDownIcon, ThumbUpIcon } from "./icons"; import { Button } from "../ui/button"; import { Tooltip, - TooltipContent, + BetterTooltip, TooltipProvider, TooltipTrigger, } from "../ui/tooltip"; @@ -50,7 +50,7 @@ export function MessageActions({ - Copy + @@ -105,7 +105,7 @@ export function MessageActions({ - Upvote Response + @@ -160,7 +160,7 @@ export function MessageActions({ - Downvote Response +
diff --git a/components/custom/message.tsx b/components/custom/message.tsx index 67bde09..b577de4 100644 --- a/components/custom/message.tsx +++ b/components/custom/message.tsx @@ -16,6 +16,7 @@ import { Markdown } from "./markdown"; import { MessageActions } from "./message-actions"; import { PreviewAttachment } from "./preview-attachment"; import { Weather } from "./weather"; +import { messageAnimationVariants } from "@/lib/animation-variants"; export const PreviewMessage = ({ chatId, @@ -71,10 +72,12 @@ export const PreviewMessage = ({ return (
{ return ( -
-
- -
+
+ +
-
-
- Thinking... -
+
+
+ Thinking...
diff --git a/components/custom/multimodal-input.tsx b/components/custom/multimodal-input.tsx index 5966154..8160422 100644 --- a/components/custom/multimodal-input.tsx +++ b/components/custom/multimodal-input.tsx @@ -1,111 +1,52 @@ "use client"; import cx from "classnames"; -import { motion } from "framer-motion"; -import { X } from "lucide-react"; -import React, { - useRef, - useEffect, - useState, - useCallback, - Dispatch, - SetStateAction, - ChangeEvent, -} from "react"; +import React, { useRef, useCallback } from "react"; import { toast } from "sonner"; -import { useLocalStorage, useWindowSize } from "usehooks-ts"; +import { useLocalStorage } from "usehooks-ts"; +import { useAccount } from 'wagmi'; +import { + IoArrowUpOutline, + IoAttachOutline, + IoStopOutline, + IoGlobeOutline, + IoWalletOutline, + IoSquareOutline, +} from "react-icons/io5"; import { useWalletState } from "@/hooks/useWalletState"; -import { createClient } from "@/lib/supabase/client"; -import { sanitizeUIMessages } from "@/lib/utils"; - -import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons"; -import { PreviewAttachment } from "./preview-attachment"; import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; -import { ChatSkeleton } from "./chat-skeleton"; - -import type { Attachment as SupabaseAttachment } from "@/types/supabase"; -import type { - Attachment, - ChatRequestOptions, - CreateMessage, - Message, -} from "ai"; +import type { MultimodalInputProps } from "@/types/chat"; -const suggestedActions = [ +const SUGGESTED_ACTIONS = [ { title: "Create a new document", - label: 'with the title "My New Document"', + label: "with title", action: 'Create a new document with the title "My New Document"', }, - { - title: "Update an existing document", - label: 'with the description "Add more details"', - action: - 'Update the document with ID "123" with the description "Add more details"', - }, - { - title: "Request suggestions for a document", - label: 'with ID "123"', - action: 'Request suggestions for the document with ID "123"', - }, - { - title: "Get the current weather", - label: "in San Francisco", - action: "Get the current weather in San Francisco", - }, { title: "Check wallet balance", - label: "for my connected wallet", + label: "for connected wallet", action: "Check the balance of my connected wallet", }, { - title: "Check wallet state", - label: "for my connected wallet", - action: "Check the state of my connected wallet", + title: "Web search", + label: "search the web for information", + action: "Search the web for latest blockchain news", }, -]; -// Add type for temp attachments -type TempAttachment = { - url: string; - name: string; - contentType: string; - path?: string; -}; - -// Add type for staged files -interface StagedFile { - id: string; - file: File; - previewUrl: string; - status: "staging" | "uploading" | "complete" | "error"; -} - -interface MultimodalInputProps { - input: string; - setInput: (value: string) => void; - isLoading: boolean; - stop: () => void; - attachments: Attachment[]; - setAttachments: Dispatch>; - messages: Message[]; - setMessages: Dispatch>; - append: ( - message: Message | CreateMessage, - chatRequestOptions?: ChatRequestOptions, - ) => Promise; - handleSubmit: ( - event?: { preventDefault?: () => void }, - chatRequestOptions?: ChatRequestOptions, - ) => void; - className?: string; - chatId: string; -} + { + title: "Smart contract interaction", + label: "interact with contracts", + action: "Show me how to interact with a smart contract", + }, +] as const; export function MultimodalInput({ + chatId, input, setInput, + handleSubmit: formSubmit, isLoading, stop, attachments, @@ -113,503 +54,242 @@ export function MultimodalInput({ messages, setMessages, append, - handleSubmit, className, - chatId, + webSearchEnabled = true, }: MultimodalInputProps) { const textareaRef = useRef(null); - const { width } = useWindowSize(); - const supabase = createClient(); - const { address, isConnected, chainId, networkInfo, isCorrectNetwork } = - useWalletState(); - - const [uploadProgress, setUploadProgress] = useState(0); - const [stagedFiles, setStagedFiles] = useState([]); - const [expectingText, setExpectingText] = useState(false); - const stagedFileNames = useRef>(new Set()); - - useEffect(() => { - if (textareaRef.current) { - adjustHeight(); + const fileInputRef = useRef(null); + const [localInput, setLocalInput] = useLocalStorage("chat-input", ""); + const { isConnected, isCorrectNetwork } = useWalletState(); + const { address } = useAccount(); + + // Web search handler with better formatting + const handleWebSearch = useCallback(async () => { + const searchText = input.trim(); + if (!searchText) { + toast.error("Please enter a search query"); + return; } - }, []); - const adjustHeight = () => { - if (textareaRef.current) { - textareaRef.current.style.height = "auto"; - textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`; - } - }; + try { + const response = await fetch( + `/api/search?query=${encodeURIComponent(searchText)}` + ); + + if (!response.ok) { + throw new Error("Search failed"); + } - const [localStorageInput, setLocalStorageInput] = useLocalStorage( - "input", - "", - ); + const data = await response.json(); + + // Improved formatting for search results + const formattedResults = data.results + .map((result: any, index: number) => ( + `${index + 1}. ${result.Text}\n${result.FirstURL ? ` Link: ${result.FirstURL}\n` : ''}` + )) + .join('\n'); - useEffect(() => { - if (textareaRef.current) { - const domValue = textareaRef.current.value; - // Prefer DOM value over localStorage to handle hydration - const finalValue = domValue || localStorageInput || ""; - setInput(finalValue); - adjustHeight(); - } - // Only run once after hydration - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const searchMessage = `🔍 **Web Search Results**\n\nQuery: "${searchText}"\n\n${formattedResults}\n\n---\nResults powered by DuckDuckGo`; - useEffect(() => { - setLocalStorageInput(input); - }, [input, setLocalStorageInput]); + // Append search results to chat + await append( + { + role: "assistant", + content: searchMessage, + } + ); - const handleInput = (event: React.ChangeEvent) => { - setInput(event.target.value); - adjustHeight(); - }; + // Also append the user's query + await append( + { + role: "user", + content: `Search: ${searchText}`, + } + ); - const fileInputRef = useRef(null); + setInput(""); + setLocalInput(""); + } catch (error) { + console.error("Search error:", error); + toast.error("Failed to perform web search"); + } + }, [input, append, setInput, setLocalInput]); - // Create blob URLs for file previews - const createStagedFile = useCallback((file: File): StagedFile => { - return { - id: crypto.randomUUID(), - file, - previewUrl: URL.createObjectURL(file), - status: "staging", - }; - }, []); + // Add balance check handler + const handleBalanceCheck = useCallback(async () => { + if (!address) { + toast.error("Please connect your wallet first"); + return; + } - // Clean up blob URLs when files are removed - const removeStagedFile = useCallback((fileId: string) => { - setStagedFiles((prev) => { - const file = prev.find((f) => f.id === fileId); - if (file) { - URL.revokeObjectURL(file.previewUrl); - } - const updatedFiles = prev.filter((f) => f.id !== fileId); - if (file) { - stagedFileNames.current.delete(file.file.name); + try { + // Fetch both basic balance and DeBankAPI data + const [ethResponse, debankResponse] = await Promise.all([ + fetch(`/api/wallet/balance?address=${address}&network=base-sepolia`), + fetch(`/api/wallet/debank?address=${address}`), + ]); + + if (!ethResponse.ok || !debankResponse.ok) { + throw new Error("Balance check failed"); } - return updatedFiles; - }); - }, []); - // Clean up all blob URLs on unmount - useEffect(() => { - return () => { - stagedFiles.forEach((file) => { - URL.revokeObjectURL(file.previewUrl); - }); - }; - }, [stagedFiles]); + const [ethData, debankData] = await Promise.all([ + ethResponse.json(), + debankResponse.json(), + ]); + + // Format the balance message with both ETH and token balances + const tokenList = debankData.tokens + .filter((token: any) => token.usd_value > 1) // Only show tokens worth more than $1 + .map((token: any) => + `- ${token.symbol}: ${Number(token.balance).toFixed(4)} (${token.chain}) ≈ $${token.usd_value.toFixed(2)}` + ) + .join('\n'); + + const balanceMessage = `💰 **Wallet Balance**\n\n` + + `Address: \`${address}\`\n` + + `Network: Base Sepolia\n` + + `ETH Balance: ${Number(ethData.balance).toFixed(4)} ETH\n\n` + + `**Total Portfolio Value:** $${debankData.totalBalance.total_usd_value.toFixed(2)}\n\n` + + `**Token Balances:**\n${tokenList}\n\n` + + `---\nLast updated: ${new Date().toLocaleString()}`; + + // Append balance info to chat + await append( + { + role: "assistant", + content: balanceMessage, + } + ); - const submitForm = useCallback(async () => { - if (!input && attachments.length === 0) return; + } catch (error) { + console.error("Balance check error:", error); + toast.error("Failed to check wallet balance"); + } + }, [address, append]); + + // Update submit handler to include balance check + const onSubmit = useCallback(async () => { + const searchText = input.trim(); + if (!searchText && attachments.length === 0) { + toast.error("Please enter a message or add an attachment"); + return; + } - const isWalletQuery = - input.toLowerCase().includes("wallet") || - input.toLowerCase().includes("balance"); + const isWalletQuery = searchText.toLowerCase().includes("wallet") || + searchText.toLowerCase().includes("balance"); + const isWebSearch = searchText.toLowerCase().includes("search"); - // Set expecting text based on input type - setExpectingText(true); + if (isWalletQuery && (!isConnected || !isCorrectNetwork)) { + toast.error("Please connect your wallet and ensure correct network"); + return; + } - if (isWalletQuery) { - if (!isConnected) { - toast.error("Please connect your wallet first"); + try { + if (isWalletQuery) { + await handleBalanceCheck(); return; } - if (!isCorrectNetwork) { - toast.error("Please switch to Base Mainnet or Base Sepolia"); + + if (isWebSearch && webSearchEnabled) { + await handleWebSearch(); return; } - } - const messageContent = isWalletQuery - ? { - text: input, - attachments: attachments.map((att) => ({ - url: att.url, - name: att.name, - type: att.contentType, - })), - walletAddress: address, - chainId, - network: networkInfo?.name, - isWalletConnected: isConnected, - isCorrectNetwork, - } - : { - text: input, - attachments: attachments.map((att) => ({ - url: att.url, - name: att.name, - type: att.contentType, - })), - }; - - try { await append( { - role: "user", - content: JSON.stringify(messageContent), - }, - { - experimental_attachments: attachments, + role: "user", + content: searchText, }, + { experimental_attachments: attachments } ); setInput(""); setAttachments([]); - setLocalStorageInput(""); + setLocalInput(""); } catch (error) { - console.error("Error sending message:", error); toast.error("Failed to send message"); - } finally { - // Reset expectingText when response is received - setExpectingText(false); } }, [ input, attachments, append, - setInput, - setLocalStorageInput, - address, - chainId, - setAttachments, isConnected, isCorrectNetwork, - networkInfo, + handleWebSearch, + handleBalanceCheck, + webSearchEnabled, + setInput, + setAttachments, + setLocalInput, ]); - const handleSuggestedAction = useCallback( - (action: string) => { - const isWalletAction = - action.toLowerCase().includes("wallet") || - action.toLowerCase().includes("balance"); - - if (isWalletAction) { - if (!isConnected) { - toast.error("Please connect your wallet first"); - return; - } - if (!isCorrectNetwork) { - toast.error("Please switch to Base Mainnet or Base Sepolia"); - return; - } - } - - setInput(action); - submitForm(); - }, - [isConnected, isCorrectNetwork, setInput, submitForm], - ); - - const handleFileChange = useCallback( - async (event: ChangeEvent) => { - const files = Array.from(event.target.files || []); - - // Create staged files with blob URLs - const newStagedFiles = files - .filter((file) => !stagedFileNames.current.has(file.name)) - .map((file) => { - stagedFileNames.current.add(file.name); - return createStagedFile(file); - }); - setStagedFiles((prev) => [...prev, ...newStagedFiles]); - - try { - // Upload each file - for (const stagedFile of newStagedFiles) { - setStagedFiles((prev) => - prev.map((f) => - f.id === stagedFile.id ? { ...f, status: "uploading" } : f, - ), - ); - - const formData = new FormData(); - formData.append("file", stagedFile.file); - formData.append("chatId", chatId); - - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); - - if (!response.ok) throw new Error("Upload failed"); - - const data = await response.json(); - - // Add to attachments on successful upload - setAttachments((current) => [ - ...current, - { - url: data.url, - name: stagedFile.file.name, - contentType: stagedFile.file.type, - path: data.path, - }, - ]); - - // Mark as complete and remove from staged files - setStagedFiles((prev) => - prev.map((f) => - f.id === stagedFile.id ? { ...f, status: "complete" } : f, - ), - ); - removeStagedFile(stagedFile.id); - } - - toast.success("Files uploaded successfully"); - } catch (error) { - console.error("Error uploading files:", error); - toast.error("Failed to upload one or more files"); - - // Mark failed files - newStagedFiles.forEach((file) => { - setStagedFiles((prev) => - prev.map((f) => (f.id === file.id ? { ...f, status: "error" } : f)), - ); - }); - } finally { - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - } - }, - [chatId, createStagedFile, removeStagedFile, setAttachments], - ); - - // Focus management - useEffect(() => { - if (textareaRef.current) { - textareaRef.current.focus(); - } - }, [messages.length]); // Refocus after new message - - // Auto-focus on mount - useEffect(() => { - const timer = setTimeout(() => { - textareaRef.current?.focus(); - }, 100); - return () => clearTimeout(timer); - }, []); - - const handlePaste = useCallback( - async (e: React.ClipboardEvent) => { - console.log("🔍 Paste event detected"); - - const clipboardData = e.clipboardData; - if (!clipboardData) return; - - // Check for images in clipboard - const items = Array.from(clipboardData.items); - const imageItems = items.filter( - (item) => item.kind === "file" && item.type.startsWith("image/"), - ); - - if (imageItems.length > 0) { - e.preventDefault(); - console.log("📸 Found image in clipboard"); - - // Convert clipboard items to files - const files = imageItems - .map((item) => item.getAsFile()) - .filter((file): file is File => file !== null) - .map( - (file) => - new File( - [file], - `screenshot-${Date.now()}.${file.type.split("/")[1] || "png"}`, - { type: file.type }, - ), - ); - - // Create staged files with blob URLs - const newStagedFiles = files.map(createStagedFile); - setStagedFiles((prev) => [...prev, ...newStagedFiles]); - - try { - // Upload each file using existing upload logic - for (const stagedFile of newStagedFiles) { - setStagedFiles((prev) => - prev.map((f) => - f.id === stagedFile.id ? { ...f, status: "uploading" } : f, - ), - ); - - const formData = new FormData(); - formData.append("file", stagedFile.file); - formData.append("chatId", chatId); - - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); - - if (!response.ok) throw new Error("Upload failed"); - - const data = await response.json(); - - // Add to attachments on successful upload - setAttachments((current) => [ - ...current, - { - url: data.url, - name: stagedFile.file.name, - contentType: stagedFile.file.type, - path: data.path, - }, - ]); - - // Mark as complete and remove from staged files - setStagedFiles((prev) => - prev.map((f) => - f.id === stagedFile.id ? { ...f, status: "complete" } : f, - ), - ); - removeStagedFile(stagedFile.id); - } - - toast.success("Files uploaded successfully"); - } catch (error) { - console.error("Error uploading files:", error); - toast.error("Failed to upload one or more files"); - - // Mark failed files - newStagedFiles.forEach((file) => { - setStagedFiles((prev) => - prev.map((f) => - f.id === file.id ? { ...f, status: "error" } : f, - ), - ); - }); - } - } - }, - [chatId, createStagedFile, removeStagedFile, setAttachments], - ); - return (
- {isLoading && expectingText && ( -
-
-
-
-
- )} - - {messages.length === 0 && - attachments.length === 0 && - stagedFiles.length === 0 && ( -
- {suggestedActions.map((suggestedAction, index) => ( - 1 ? "hidden sm:block" : "block")} - > - - - ))} -
- )} - - - - {(attachments.length > 0 || stagedFiles.length > 0) && ( -
- {stagedFiles.map((stagedFile) => ( -
- removeStagedFile(stagedFile.id)} - /> - {stagedFile.status === "error" && ( -
- - Upload failed - -
- )} -
- ))} - - {attachments.map((attachment) => ( -
- - setAttachments((current) => - current.filter((a) => a.url !== attachment.url) - ) - } - /> -
+ {/* Suggested Actions */} + {messages.length === 0 && ( +
+ {SUGGESTED_ACTIONS.map((action) => ( + ))}
)} + {/* Input Area */}