From de0536fa794bd766f877416768edbd116d617544 Mon Sep 17 00:00:00 2001 From: "balyan.sid@gmail.com" Date: Thu, 5 Jun 2025 12:56:40 -0700 Subject: [PATCH] feat: add toolbar with composio tools. this will let people enable, disable and connect to composio tools. have to implement the functionality of saving the connected toolkit state and then using the composio tools. next things: - saving the enabled/disabled state of the toolkits locally - sending the toolkit slugs to chat route on sending message. - implement UI for showing tool calls - implement UI for connecting with a tool during a chat session - use `useSWR` and wrap the toolkit endpoint in cache --- .gitignore | 3 +- CLAUDE.md | 18 +- app/(chat)/api/chat/route.ts | 19 +- app/(chat)/api/chat/schema.ts | 1 + app/(chat)/layout.tsx | 17 +- app/api/connections/initiate/route.ts | 57 ++ app/api/connections/initiate/schema.ts | 9 + app/api/toolkits/route.ts | 93 ++++ app/api/toolkits/schema.ts | 7 + components/chat-header.tsx | 18 +- components/chat.tsx | 3 + components/toolbar.tsx | 725 +++++++++++++++++++++++++ components/ui/dialog.tsx | 122 +++++ components/ui/switch.tsx | 29 + components/ui/toast.tsx | 129 +++++ components/ui/toaster.tsx | 31 ++ composio-docs.md | 14 + hooks/use-toast.ts | 191 +++++++ lib/ai/tools/composio.ts | 21 + lib/services/composio.ts | 14 + next.config.ts | 3 + package.json | 7 +- pnpm-lock.yaml | 402 +++++++++++++- 23 files changed, 1892 insertions(+), 41 deletions(-) create mode 100644 app/api/connections/initiate/route.ts create mode 100644 app/api/connections/initiate/schema.ts create mode 100644 app/api/toolkits/route.ts create mode 100644 app/api/toolkits/schema.ts create mode 100644 components/toolbar.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 composio-docs.md create mode 100644 hooks/use-toast.ts create mode 100644 lib/ai/tools/composio.ts create mode 100644 lib/services/composio.ts diff --git a/.gitignore b/.gitignore index 6459f04..d3055b1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ yarn-error.log* /playwright-report/ /blob-report/ /playwright/* -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo +docs/composio-toolkit-metadata.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b1efc81..c9d7c2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a Next.js AI chatbot application using: ### Core Stack + - **Next.js 15** with App Router and React Server Components - **AI SDK** for LLM integration with Anthropic Claude models - **Drizzle ORM** with PostgreSQL for data persistence @@ -29,6 +30,7 @@ This is a Next.js AI chatbot application using: - **shadcn/ui** components with Tailwind CSS ### Project Structure + - `/app/(auth)/` - Authentication routes and components - `/app/(chat)/` - Main chat interface and API routes - `/artifacts/` - Different artifact types (code, image, text, sheet) @@ -39,18 +41,21 @@ This is a Next.js AI chatbot application using: - `/tests/` - Playwright e2e tests ### AI Integration + - Default model: `claude-4-sonnet-20250514` for chat, `claude-3-5-haiku-latest` for titles - Configurable via `/lib/ai/providers.ts` - Test environment uses mock models from `/lib/ai/models.test.ts` - AI tools defined in `/lib/ai/tools/` ### Database Schema + - Uses Drizzle ORM with PostgreSQL - Core tables: User, Chat, Message_v2 (new schema), Document, Vote, Suggestion - Migration files in `/lib/db/migrations/` - The Message table is deprecated in favor of Message_v2 ### Key Features + - Real-time chat with artifacts (code, documents, images, spreadsheets) - File upload and document processing - Chat history with public/private visibility @@ -58,6 +63,7 @@ This is a Next.js AI chatbot application using: - Multi-modal input support ## Code Quality + - Uses Biome for linting and formatting - ESLint for Next.js specific rules - TypeScript with strict configuration @@ -66,6 +72,7 @@ This is a Next.js AI chatbot application using: ## Development Best Practices ### Authentication Changes + - **Plan schema completely upfront** - Design all auth-related fields in single migration - **Exclude auth routes from middleware** - Always exempt `/login`, `/register` from auth checks first - **Use unique identifiers over emails** - OAuth provider IDs are more reliable than email addresses @@ -73,22 +80,31 @@ This is a Next.js AI chatbot application using: - **Avoid type safety compromises** - Use proper optional types instead of non-null assertions ### Database Migrations + - **Single comprehensive migration over incremental** - Plan all related fields together - **Include constraints from start** - Define unique, foreign key constraints in initial schema - **Clean slate approach for development** - Clear test data when making major auth changes ### Middleware and Routing + - **Auth exclusions first** - Always handle public routes before implementing auth redirects - **Avoid redirect loops** - Check middleware patterns don't redirect auth pages to themselves ### Error Handling + - **Detailed logging for auth flows** - Log profile data and validation steps for debugging - **Graceful fallbacks** - Design for missing optional data (emails, names) from OAuth providers ### Code Quality During Development + - **Run `pnpm format` and `pnpm lint`** after implementing any granular part of functionality - **Avoid type safety compromises** - No ts-ignores or lint ignores, use proper typing instead - **Test incrementally** - Verify each step works before moving to the next ### Git Workflow -- Use gh cli when working with git stuff. If and when asked, make PRs using it too. \ No newline at end of file + +- Use gh cli when working with git stuff. If and when asked, make PRs using it too. + +## Composio Documentation + +- For composio related documentation, read composio-docs.md to find the relevant URLs to read the documentation from. diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index fff5e21..6acaf96 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -33,6 +33,7 @@ import { after } from 'next/server'; import type { Chat } from '@/lib/db/schema'; import { differenceInSeconds } from 'date-fns'; import { ChatSDKError } from '@/lib/errors'; +import { getComposioTools } from '@/lib/ai/tools/composio'; export const maxDuration = 60; @@ -69,8 +70,13 @@ export async function POST(request: Request) { } try { - const { id, message, selectedChatModel, selectedVisibilityType } = - requestBody; + const { + id, + message, + selectedChatModel, + selectedVisibilityType, + enabledToolkits, + } = requestBody; const session = await auth(); @@ -142,7 +148,13 @@ export async function POST(request: Request) { await createStreamId({ streamId, chatId: id }); const stream = createDataStream({ - execute: (dataStream) => { + execute: async (dataStream) => { + // Fetch Composio tools if toolkits are enabled + const composioTools = await getComposioTools( + session.user.id, + enabledToolkits || [], + ); + const result = streamText({ model: myProvider.languageModel(selectedChatModel), system: systemPrompt({ selectedChatModel, requestHints }), @@ -154,6 +166,7 @@ export async function POST(request: Request) { experimental_generateMessageId: generateUUID, tools: { getWeather, + ...composioTools, }, onFinish: async ({ response }) => { if (session.user?.id) { diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts index a452dc4..c568cc3 100644 --- a/app/(chat)/api/chat/schema.ts +++ b/app/(chat)/api/chat/schema.ts @@ -25,6 +25,7 @@ export const postRequestBodySchema = z.object({ }), selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']), selectedVisibilityType: z.enum(['public', 'private']), + enabledToolkits: z.array(z.string()).optional(), }); export type PostRequestBody = z.infer; diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index 9310a10..e2b3c2b 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -2,6 +2,7 @@ import { cookies } from 'next/headers'; import { AppSidebar } from '@/components/app-sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { ToolBar, ToolbarProvider, ToolbarRail } from '@/components/toolbar'; import { auth } from '../(auth)/auth'; import Script from 'next/script'; @@ -21,10 +22,18 @@ export default async function Layout({ src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js" strategy="beforeInteractive" /> - - - {children} - + +
+ + + + {children} + + + + +
+
); } diff --git a/app/api/connections/initiate/route.ts b/app/api/connections/initiate/route.ts new file mode 100644 index 0000000..c2f4dcc --- /dev/null +++ b/app/api/connections/initiate/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/app/(auth)/auth'; +import composio from '@/lib/services/composio'; +import { initiateConnectionSchema } from './schema'; +import { ChatSDKError } from '@/lib/errors'; +// ConnectionRequest type is not exported from @composio/core +type ConnectionRequestResponse = { + id: string; + redirectUrl?: string | null; +}; + +export async function POST(request: Request) { + const session = await auth(); + + if (!session?.user?.id) { + return new ChatSDKError('unauthorized:chat').toResponse(); + } + + let requestBody: { authConfigId: string }; + + try { + const json = await request.json(); + requestBody = initiateConnectionSchema.parse(json); + } catch (_) { + return new ChatSDKError( + 'bad_request:api', + 'Invalid request body', + ).toResponse(); + } + + try { + const { authConfigId } = requestBody; + + // Initiate connection with Composio + const connectionRequest = (await composio.connectedAccounts.initiate( + session.user.id, + authConfigId, + )) as ConnectionRequestResponse; + + return NextResponse.json({ + redirectUrl: connectionRequest.redirectUrl, + connectionId: connectionRequest.id, + }); + } catch (error) { + console.error('Failed to initiate connection:', error); + + if (error instanceof Error && 'code' in error) { + // Handle Composio specific errors + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json( + { error: 'Failed to initiate connection' }, + { status: 500 }, + ); + } +} diff --git a/app/api/connections/initiate/schema.ts b/app/api/connections/initiate/schema.ts new file mode 100644 index 0000000..8f45e6b --- /dev/null +++ b/app/api/connections/initiate/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const initiateConnectionSchema = z.object({ + authConfigId: z.string().min(1), +}); + +export type InitiateConnectionRequest = z.infer< + typeof initiateConnectionSchema +>; diff --git a/app/api/toolkits/route.ts b/app/api/toolkits/route.ts new file mode 100644 index 0000000..b8dbd7e --- /dev/null +++ b/app/api/toolkits/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/app/(auth)/auth'; +import composio from '@/lib/services/composio'; +import { ChatSDKError } from '@/lib/errors'; + +// Using custom types since they're not exported from @composio/core +type ToolkitResponse = { + name: string; + slug: string; + meta?: { + description?: string; + logo?: string; + categories?: Array<{ + name: string; + slug: string; + }>; + }; +}; + +type ConnectedAccount = { + id: string; + toolkit: { + slug: string; + }; +}; + +// Hardcoded list of supported toolkits +const SUPPORTED_TOOLKITS = [ + 'GMAIL', + 'GOOGLECALENDAR', + 'GITHUB', + 'NOTION', + 'SLACK', + 'LINEAR', +]; + +export async function GET() { + const session = await auth(); + + if (!session?.user?.id) { + return new ChatSDKError('unauthorized:chat').toResponse(); + } + + try { + // Fetch connected accounts for the user + const connectedToolkitSlugs: Set = new Set(); + + try { + const connectedAccounts = await composio.connectedAccounts.list({ + userIds: [session.user.id], + }); + + // Extract toolkit slugs from connected accounts + connectedAccounts.items.forEach((account: ConnectedAccount) => { + if (account.toolkit?.slug) { + connectedToolkitSlugs.add(account.toolkit.slug.toUpperCase()); + } + }); + } catch (error) { + console.error('Failed to fetch connected accounts:', error); + // Continue without connection status if this fails + } + + // Fetch all toolkits in parallel + const toolkitPromises = SUPPORTED_TOOLKITS.map(async (slug) => { + try { + const toolkit = (await composio.toolkits.get(slug)) as ToolkitResponse; + return { + name: toolkit.name, + slug: toolkit.slug, + description: toolkit.meta?.description, + logo: toolkit.meta?.logo, + categories: toolkit.meta?.categories, + isConnected: connectedToolkitSlugs.has(slug.toUpperCase()), + }; + } catch (error) { + console.error(`Failed to fetch toolkit ${slug}:`, error); + return null; + } + }); + + const results = await Promise.all(toolkitPromises); + const toolkits = results.filter((t) => t !== null); + + return NextResponse.json({ toolkits }); + } catch (error) { + console.error('Failed to fetch toolkits:', error); + return NextResponse.json( + { error: 'Failed to fetch toolkits' }, + { status: 500 }, + ); + } +} diff --git a/app/api/toolkits/schema.ts b/app/api/toolkits/schema.ts new file mode 100644 index 0000000..b258e80 --- /dev/null +++ b/app/api/toolkits/schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const toolkitsRequestSchema = z.object({ + toolkitSlugs: z.array(z.string()).min(1), +}); + +export type ToolkitsRequest = z.infer; diff --git a/components/chat-header.tsx b/components/chat-header.tsx index f21f151..d0b5cfb 100644 --- a/components/chat-header.tsx +++ b/components/chat-header.tsx @@ -1,18 +1,17 @@ 'use client'; - -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useWindowSize } from 'usehooks-ts'; import { ModelSelector } from '@/components/model-selector'; import { SidebarToggle } from '@/components/sidebar-toggle'; import { Button } from '@/components/ui/button'; -import { PlusIcon, VercelIcon } from './icons'; +import { PlusIcon } from './icons'; import { useSidebar } from './ui/sidebar'; import { memo } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { type VisibilityType, VisibilitySelector } from './visibility-selector'; import type { Session } from 'next-auth'; +import { ToolBarTrigger } from './toolbar'; function PureChatHeader({ chatId, @@ -71,18 +70,7 @@ function PureChatHeader({ /> )} - + ); } diff --git a/components/chat.tsx b/components/chat.tsx index 89e472f..1f99a63 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -18,6 +18,7 @@ import { useSearchParams } from 'next/navigation'; import { useChatVisibility } from '@/hooks/use-chat-visibility'; import { useAutoResume } from '@/hooks/use-auto-resume'; import { ChatSDKError } from '@/lib/errors'; +import { useToolbarState } from './toolbar'; export function Chat({ id, @@ -37,6 +38,7 @@ export function Chat({ autoResume: boolean; }) { const { mutate } = useSWRConfig(); + const { state: toolbarState } = useToolbarState(); const { visibilityType } = useChatVisibility({ chatId: id, @@ -67,6 +69,7 @@ export function Chat({ message: body.messages.at(-1), selectedChatModel: initialChatModel, selectedVisibilityType: visibilityType, + enabledToolkits: Array.from(toolbarState.enabledTools), }), onFinish: () => { mutate(unstable_serialize(getChatHistoryPaginationKey)); diff --git a/components/toolbar.tsx b/components/toolbar.tsx new file mode 100644 index 0000000..7cf8739 --- /dev/null +++ b/components/toolbar.tsx @@ -0,0 +1,725 @@ +'use client'; + +import * as React from 'react'; +import Image from 'next/image'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { ExternalLink, Loader2, X, Wrench, Check } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { useToast } from '@/hooks/use-toast'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; + +// Define the toolkit slugs we want to show +const SUPPORTED_TOOLKITS = [ + 'GMAIL', + 'GOOGLECALENDAR', + 'GITHUB', + 'NOTION', + 'SLACK', + 'LINEAR', +]; + +// Map of toolkit slugs to their auth config environment variables +const TOOLKIT_AUTH_CONFIG: Record = { + GMAIL: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_GMAIL || '', + GOOGLECALENDAR: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_GOOGLECALENDAR || '', + GITHUB: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_GITHUB || '', + NOTION: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_NOTION || '', + SLACK: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_SLACK || '', + LINEAR: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_LINEAR || '', +}; + +const TOOLBAR_COOKIE_NAME = 'toolbar:state'; +const TOOLBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const TOOLBAR_WIDTH = '20rem'; +const TOOLBAR_WIDTH_MOBILE = '18rem'; +const TOOLBAR_KEYBOARD_SHORTCUT = 't'; + +// Define toolkit metadata type for our UI needs +type ToolkitMetadata = { + name: string; + slug: string; + description?: string; + logo?: string; + categories?: Array<{ + name: string; + slug: string; + }>; + isConnected?: boolean; +}; + +interface ToolCardProps { + toolkit: ToolkitMetadata; + isEnabled: boolean; + onToggle: (enabled: boolean) => void; + userId?: string; +} + +function ToolCard({ toolkit, isEnabled, onToggle, userId }: ToolCardProps) { + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const [isConnecting, setIsConnecting] = React.useState(false); + const { toast } = useToast(); + + const handleConnect = async () => { + if (!userId) { + toast({ + title: 'Authentication required', + description: 'Please sign in to connect tools.', + variant: 'destructive', + }); + return; + } + + const authConfigId = TOOLKIT_AUTH_CONFIG[toolkit.slug.toUpperCase()]; + + if (!authConfigId) { + toast({ + title: 'Configuration error', + description: `Auth configuration not found for ${toolkit.name}.`, + variant: 'destructive', + }); + return; + } + + setIsConnecting(true); + try { + const response = await fetch('/api/connections/initiate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ authConfigId }), + }); + + if (!response.ok) { + throw new Error('Failed to initiate connection'); + } + + const data = await response.json(); + const { redirectUrl } = data; + + if (redirectUrl) { + window.open(redirectUrl, '_blank'); + toast({ + title: 'Authorization started', + description: `Complete the authorization in the opened window for ${toolkit.name}.`, + }); + } else { + toast({ + title: 'Connection issue', + description: 'No authorization URL received. Please try again.', + variant: 'destructive', + }); + } + } catch (error) { + console.error('Failed to initiate connection:', error); + toast({ + title: 'Connection failed', + description: `Failed to connect ${toolkit.name}. Please try again.`, + variant: 'destructive', + }); + } finally { + setIsConnecting(false); + setIsDialogOpen(false); + } + }; + + return ( + + +
+
+ {toolkit.logo ? ( + {`${toolkit.name} + ) : ( +
+ )} + {toolkit.name} +
+ +
+ + + + {toolkit.description || `Connect to ${toolkit.name}`} + + + + {toolkit.isConnected ? ( + + ) : ( + + + + + + + Connect to {toolkit.name} + + To connect your {toolkit.name} account, you need to authorize + this application. + + +
+

+ Click the button below to proceed to the authorization page + for {toolkit.name}. A new window will open where you can + securely connect your account. +

+
+ + + + + + +
+
+ )} +
+ + ); +} + +type ToolbarContext = { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleToolbar: () => void; + enabledTools: Set; + setEnabledTools: React.Dispatch>>; +}; + +const ToolbarContext = React.createContext(null); + +function useToolbar() { + const context = React.useContext(ToolbarContext); + if (!context) { + throw new Error('useToolbar must be used within a ToolbarProvider.'); + } + return context; +} + +export function useToolbarState() { + const { enabledTools, setEnabledTools } = useToolbar(); + return { + state: { enabledTools }, + setState: ( + updater: React.SetStateAction<{ enabledTools: Set }>, + ) => { + if (typeof updater === 'function') { + const newState = updater({ enabledTools }); + setEnabledTools(newState.enabledTools); + } else { + setEnabledTools(updater.enabledTools); + } + }, + }; +} + +export function useToolbarVisibility() { + const { open, setOpen } = useToolbar(); + return { isOpen: open, setIsOpen: setOpen }; +} + +export function ToolbarProvider({ children }: { children: React.ReactNode }) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + const [enabledTools, setEnabledTools] = React.useState>( + new Set(), + ); + + // This is the internal state of the toolbar. + const [_open, _setOpen] = React.useState(false); + const open = _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value; + _setOpen(openState); + + // This sets the cookie to keep the toolbar state. + document.cookie = `${TOOLBAR_COOKIE_NAME}=${openState}; path=/; max-age=${TOOLBAR_COOKIE_MAX_AGE}`; + }, + [open], + ); + + // Helper to toggle the toolbar. + const toggleToolbar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the toolbar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === TOOLBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleToolbar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleToolbar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + const state = open ? 'expanded' : 'collapsed'; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleToolbar, + enabledTools, + setEnabledTools, + }), + [ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleToolbar, + enabledTools, + setEnabledTools, + ], + ); + + return ( + + {children} + + ); +} + +function ToolbarDesktop() { + const { data: session } = useSession(); + const { state, enabledTools, setEnabledTools } = useToolbar(); + const [toolkits, setToolkits] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [fetchError, setFetchError] = React.useState(null); + + React.useEffect(() => { + async function fetchToolkits() { + setIsLoading(true); + setFetchError(null); + try { + const response = await fetch('/api/toolkits'); + + if (!response.ok) { + throw new Error('Failed to fetch toolkits'); + } + + const { toolkits } = await response.json(); + setToolkits(toolkits); + } catch (error) { + console.error('Failed to fetch toolkits:', error); + setFetchError('Failed to load tools. Please try again.'); + // Retry after 3 seconds + setTimeout(() => setFetchError(null), 3000); + } finally { + setIsLoading(false); + } + } + + fetchToolkits(); + }, []); + + const handleToggle = (toolSlug: string, enabled: boolean) => { + setEnabledTools((prev) => { + const newEnabledTools = new Set(prev); + if (enabled) { + newEnabledTools.add(toolSlug); + } else { + newEnabledTools.delete(toolSlug); + } + return newEnabledTools; + }); + }; + + return ( +
+ {/* This is what handles the toolbar gap on desktop */} +
+ +
+ ); +} + +function ToolbarMobile() { + const { data: session } = useSession(); + const { openMobile, setOpenMobile, enabledTools, setEnabledTools } = + useToolbar(); + const [toolkits, setToolkits] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [fetchError, setFetchError] = React.useState(null); + + React.useEffect(() => { + async function fetchToolkits() { + if (!openMobile) return; + + setIsLoading(true); + setFetchError(null); + try { + const response = await fetch('/api/toolkits'); + + if (!response.ok) { + throw new Error('Failed to fetch toolkits'); + } + + const { toolkits } = await response.json(); + setToolkits(toolkits); + } catch (error) { + console.error('Failed to fetch toolkits:', error); + setFetchError('Failed to load tools. Please try again.'); + // Retry after 3 seconds + setTimeout(() => setFetchError(null), 3000); + } finally { + setIsLoading(false); + } + } + + fetchToolkits(); + }, [openMobile]); + + const handleToggle = (toolSlug: string, enabled: boolean) => { + setEnabledTools((prev) => { + const newEnabledTools = new Set(prev); + if (enabled) { + newEnabledTools.add(toolSlug); + } else { + newEnabledTools.delete(toolSlug); + } + return newEnabledTools; + }); + }; + + return ( + + +
+ +
+ + Toolbox +
+
+ +
+
+ {isLoading ? ( +
+ +
+ ) : fetchError ? ( +
+

{fetchError}

+ +
+ ) : ( + <> +
+

+ Enable tools to enhance your chat capabilities +

+
+ {toolkits.map((toolkit) => ( + + handleToggle(toolkit.slug, enabled) + } + userId={session?.user?.id} + /> + ))} + + )} +
+
+
+
+
+ ); +} + +export function ToolBar() { + const { isMobile } = useToolbar(); + + if (isMobile) { + return ; + } + + return ; +} + +export function ToolbarTrigger() { + const { toggleToolbar } = useToolbar(); + + return ( + + + + + + + Close Toolbox (⌘T) + + + + ); +} + +export function ToolBarTrigger() { + const { toggleToolbar, open, enabledTools } = useToolbar(); + const hasEnabledTools = enabledTools.size > 0; + + return ( + + + + + + + {open + ? 'Close Toolbox' + : hasEnabledTools + ? `Open Toolbox (${enabledTools.size} active)` + : 'Open Toolbox'}{' '} + (⌘T) + + + + ); +} + +// Rail component for easier opening when collapsed +export function ToolbarRail() { + const { toggleToolbar, state } = useToolbar(); + + if (state === 'expanded') return null; + + return ( +