diff --git a/README.md b/README.md index e968482..a0dc1d7 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,421 @@ -# Tambo Template +# IndentOS -This is a starter NextJS app with Tambo hooked up to get your AI app development started quickly. +**An AI-powered intent-driven operating system that understands what you want to do and generates intelligent workflows to help you achieve it.** -## Get Started -k -1. Run `npm create-tambo@latest my-tambo-app` for a new project +IndentOS is a next-generation interface built on [Tambo AI](https://tambo.co) that bridges the gap between user intent and action. Instead of navigating through menus and clicking buttons, you simply express your intent in natural language, and IndentOS orchestrates the necessary steps, components, and tools to accomplish your goal. -2. `npm install` +--- -3. `npx tambo init` +## 🌟 What is IndentOS? -- or rename `example.env.local` to `.env.local` and add your tambo API key you can get for free [here](https://tambo.co/dashboard). +IndentOS reimagines how users interact with software by: -4. Run `npm run dev` and go to `localhost:3000` to use the app! +- **Understanding Intent**: Uses advanced AI to comprehend what users actually want to accomplish +- **Generating Workflows**: Automatically creates step-by-step workflows tailored to user goals +- **Dynamic UI Generation**: Renders the right components at the right time based on context +- **Intelligent Tool Orchestration**: Connects to various tools and services to execute tasks +- **Conversational Interface**: Natural language interaction instead of traditional point-and-click -## Customizing +### Key Concepts + +**Intent-Driven Design**: Rather than building fixed UI flows, IndentOS dynamically generates workflows based on user intent. The AI interprets what you want and creates a personalized path to achieve it. + +**Generative UI**: Components are generated on-the-fly by AI based on the conversation context, creating adaptive interfaces that respond to user needs. + +**Tool Integration**: IndentOS can invoke various tools and services (APIs, databases, external systems) to accomplish tasks, making it a powerful orchestration layer. + +--- + +## ✨ Features + +### 🤖 AI-Powered Chat Interface +- **Natural language interaction** with context-aware responses +- **Streaming responses** for real-time feedback +- **Voice input support** with dictation capabilities +- **Thread management** with conversation history +- **File attachments** with drag-and-drop support -### Change what components tambo can control +### 🎯 Intent Workflow System +- **Dynamic workflow generation** based on user goals +- **Task breakdown** with progress tracking +- **Timeline visualization** for multi-step processes +- **Elicitation system** to gather required information -You can see how components are registered with tambo in `src/lib/tambo.ts`: +### 🔧 Extensible Component System +- **Graph visualizations** (bar, line, pie charts) using Recharts +- **Data cards** for multi-select interactions +- **Custom components** registered via `src/lib/tambo.ts` +- **AI-generated UI** that adapts to context -```tsx +### 🛠️ Tool Orchestration +- **Population statistics tools** (example implementation) +- **Extensible tool system** for adding external capabilities +- **Schema-validated inputs/outputs** using Zod +- **MCP (Model Context Protocol)** support for external integrations + +### 🔐 Authentication & User Management +- **Supabase authentication** integration +- **User signup and login** flows +- **Secure session management** + +### 🎨 Modern UI/UX +- **Dark mode support** with Tailwind CSS v4 +- **Responsive design** for all screen sizes +- **Rich text editing** with TipTap +- **Markdown rendering** with code highlighting +- **Smooth animations** using Framer Motion + +--- + +## 🏗️ Technology Stack + +| Layer | Technology | +|-------|-----------| +| **Framework** | Next.js 15 with App Router | +| **UI Library** | React 19.1 | +| **Language** | TypeScript 5 | +| **AI Platform** | Tambo AI SDK (@tambo-ai/react) | +| **Styling** | Tailwind CSS v4 | +| **Authentication** | Supabase | +| **Data Visualization** | Recharts | +| **Rich Text** | TipTap | +| **Validation** | Zod | +| **Animation** | Framer Motion | +| **Icons** | Lucide React | + +--- + +## 🚀 Getting Started + +### Prerequisites + +- Node.js 18+ installed +- npm or yarn package manager +- A Tambo API key ([Get one free here](https://tambo.co/dashboard)) +- Supabase project (optional, for authentication) + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd IndentOS + ``` + +2. **Install dependencies** + ```bash + npm install + ``` + +3. **Set up environment variables** + + Copy `example.env.local` to `.env.local`: + ```bash + cp example.env.local .env.local + ``` + + Then add your API keys to `.env.local`: + ```env + NEXT_PUBLIC_TAMBO_API_KEY=your_tambo_api_key_here + NEXT_PUBLIC_SUPABASE_URL=your_supabase_url_here + NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here + ``` + +4. **Run the development server** + ```bash + npm run dev + ``` + +5. **Open your browser** + + Navigate to `http://localhost:3000` + +--- + +## 📂 Project Structure + +``` +IndentOS/ +├── src/ +│ ├── app/ # Next.js App Router pages +│ │ ├── auth/ # Authentication pages +│ │ ├── chat/ # Chat interface route +│ │ ├── login/ # Login page +│ │ ├── signup/ # Signup page +│ │ ├── interactables/ # Interactive components demo +│ │ ├── layout.tsx # Root layout with TamboProvider +│ │ └── page.tsx # Landing page +│ ├── components/ +│ │ ├── hero/ # Landing page sections +│ │ │ ├── Hero.tsx # Main hero section +│ │ │ ├── AboutSection.tsx +│ │ │ └── FAQSection.tsx +│ │ ├── tambo/ # Tambo AI components +│ │ │ ├── graph.tsx # Chart visualizations +│ │ │ ├── intent-workflow.tsx # Workflow renderer +│ │ │ ├── message-thread-full.tsx # Chat UI +│ │ │ ├── message-input.tsx # Input with file support +│ │ │ ├── text-editor.tsx # Rich text editor +│ │ │ └── mcp-*.tsx # MCP integration components +│ │ ├── ui/ # Reusable UI components +│ │ │ └── card-data.tsx # Data card component +│ │ ├── ApiKeyCheck.tsx # API key validation +│ │ ├── Navbar.tsx # Main navigation +│ │ └── Footer.tsx # Footer component +│ ├── lib/ +│ │ ├── tambo.ts # ⭐ Component & tool registration +│ │ ├── thread-hooks.ts # Thread management hooks +│ │ └── utils.ts # Utility functions +│ ├── services/ +│ │ └── population-stats.ts # Example tool implementation +│ └── middleware.ts # Auth middleware +├── public/ # Static assets +├── .env.local # Environment variables (git-ignored) +├── package.json # Dependencies +├── tailwind.config.ts # Tailwind configuration +└── tsconfig.json # TypeScript configuration +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `src/lib/tambo.ts` | **Central configuration** - Register components and tools here | +| `src/app/layout.tsx` | Root layout with TamboProvider setup | +| `src/app/chat/page.tsx` | Main chat interface | +| `src/components/tambo/intent-workflow.tsx` | Intent workflow renderer | +| `AGENTS.md` | Developer guide for AI assistants | +| `components.md` | Complete component documentation | + +--- + +## 🎯 How It Works + +### 1. Component Registration + +Components are registered in `src/lib/tambo.ts` with Zod schemas that define their props: + +```typescript export const components: TamboComponent[] = [ + { + name: "IntentWorkflow", + description: "A structured intent-driven workflow renderer", + component: IntentWorkflow, + propsSchema: intentWorkflowSchema, + }, { name: "Graph", - description: - "A component that renders various types of charts (bar, line, pie) using Recharts. Supports customizable data visualization with labels, datasets, and styling options.", + description: "Renders charts (bar, line, pie) using Recharts", component: Graph, propsSchema: graphSchema, }, - // Add more components here ]; ``` -You can install the graph component into any project with: +The AI can then dynamically render these components based on conversation context. -```bash -npx tambo add graph -``` +### 2. Tool Registration -The example Graph component demonstrates several key features: +Tools extend what the AI can do by connecting to external capabilities: -- Different prop types (strings, arrays, enums, nested objects) -- Multiple chart types (bar, line, pie) -- Customizable styling (variants, sizes) -- Optional configurations (title, legend, colors) -- Data visualization capabilities - -Update the `components` array with any component(s) you want tambo to be able to use in a response! - -You can find more information about the options [here](https://docs.tambo.co/concepts/generative-interfaces/generative-components) - -### Add tools for tambo to use - -Tools are defined with `inputSchema` and `outputSchema`: - -```tsx +```typescript export const tools: TamboTool[] = [ { name: "globalPopulation", - description: - "A tool to get global population trends with optional year range filtering", + description: "Gets global population trends with year filtering", tool: getGlobalPopulationTrend, inputSchema: z.object({ startYear: z.number().optional(), endYear: z.number().optional(), }), - outputSchema: z.array( - z.object({ - year: z.number(), - population: z.number(), - growthRate: z.number(), - }), - ), + outputSchema: z.array(z.object({ + year: z.number(), + population: z.number(), + growthRate: z.number(), + })), }, ]; ``` -Find more information about tools [here.](https://docs.tambo.co/concepts/tools) - -### The Magic of Tambo Requires the TamboProvider +### 3. TamboProvider Setup -Make sure in the TamboProvider wrapped around your app: +The `TamboProvider` wraps your app and provides AI capabilities: ```tsx -... {children} ``` -In this example we do this in the `Layout.tsx` file, but you can do it anywhere in your app that is a client component. +### 4. Intent Workflow Generation + +When a user expresses intent (e.g., "I want to plan a trip to Japan"), IndentOS: +1. **Analyzes the intent** using AI +2. **Breaks down the goal** into actionable steps +3. **Generates a workflow** with elicitation forms, tasks, and timeline +4. **Renders the UI** dynamically as the user progresses +5. **Orchestrates tools** to fetch data or perform actions + +--- + +## 🔧 Customization + +### Adding a New Component + +1. **Create the component** in `src/components/tambo/`: + ```tsx + import { z } from "zod"; + + export const myComponentSchema = z.object({ + title: z.string(), + data: z.array(z.string()), + }); + + export function MyComponent(props: z.infer) { + return
{/* Your component */}
; + } + ``` + +2. **Register it** in `src/lib/tambo.ts`: + ```tsx + import { MyComponent, myComponentSchema } from "@/components/tambo/my-component"; + + export const components: TamboComponent[] = [ + // ... existing components + { + name: "MyComponent", + description: "What this component does", + component: MyComponent, + propsSchema: myComponentSchema, + }, + ]; + ``` + +### Adding a New Tool + +1. **Implement the tool** in `src/services/`: + ```typescript + export async function myTool(params: { query: string }) { + // Your tool logic + return { result: "data" }; + } + ``` + +2. **Register it** in `src/lib/tambo.ts`: + ```tsx + export const tools: TamboTool[] = [ + // ... existing tools + { + name: "myTool", + description: "What this tool does", + tool: myTool, + inputSchema: z.object({ query: z.string() }), + outputSchema: z.object({ result: z.string() }), + }, + ]; + ``` + +--- + +## 📚 Available Scripts + +| Command | Description | +|---------|-------------| +| `npm run dev` | Start development server at `localhost:3000` | +| `npm run build` | Build production bundle | +| `npm run start` | Start production server | +| `npm run lint` | Run ESLint checks | +| `npm run lint:fix` | Run ESLint with auto-fix | +| `npx tambo init` | Initialize Tambo configuration | +| `npx tambo add ` | Add a pre-built Tambo component | + +--- + +## 🎨 Key Features Deep Dive + +### Intent Workflow Components + +The `IntentWorkflow` component renders structured workflows with: +- **Elicitation forms** to gather user input +- **Task lists** with progress tracking +- **Timeline views** for multi-step processes +- **Dynamic validation** using Zod schemas + +### Chat Interface + +The chat system includes: +- **Streaming responses** with real-time updates +- **Message history** with thread management +- **File attachments** via drag-and-drop +- **Voice input** using speech-to-text +- **MCP integration** for external prompts/resources -### Voice input +### MCP (Model Context Protocol) -The template includes a `DictationButton` component using the `useTamboVoice` hook for speech-to-text input. +Connect to external tools and resources: +- **Prompt insertion** from MCP servers +- **Resource references** with @-mentions +- **Client-side configuration** via modal -### MCP (Model Context Protocol) +--- -The template includes MCP support for connecting to external tools and resources. You can use the MCP hooks from `@tambo-ai/react/mcp`: +## 🌐 Authentication -- `useTamboMcpPromptList` - List available prompts from MCP servers -- `useTamboMcpPrompt` - Get a specific prompt -- `useTamboMcpResourceList` - List available resources +IndentOS uses Supabase for authentication: -See `src/components/tambo/mcp-components.tsx` for example usage. +- **Signup**: `/signup` - Create a new account +- **Login**: `/login` - Sign in to existing account +- **Protected routes**: Middleware handles auth checks +- **Session management**: Automatic token refresh -### Change where component responses are shown +--- -The components used by tambo are shown alongside the message response from tambo within the chat thread, but you can have the result components show wherever you like by accessing the latest thread message's `renderedComponent` field: +## 📖 Documentation -```tsx -const { thread } = useTambo(); -const latestComponent = - thread?.messages[thread.messages.length - 1]?.renderedComponent; - -return ( -
- {latestComponent && ( -
{latestComponent}
- )} -
-); -``` +- **Tambo AI Docs**: [docs.tambo.co](https://docs.tambo.co) +- **Component List**: See `components.md` for all available components +- **Developer Guide**: See `AGENTS.md` for architecture details +- **Tambo Dashboard**: [tambo.co/dashboard](https://tambo.co/dashboard) + +--- + +## 🤝 Contributing + +This is a demonstration project showing the possibilities of intent-driven interfaces. To extend it: + +1. Add new components in `src/components/tambo/` +2. Register them in `src/lib/tambo.ts` +3. Create tools in `src/services/` for external integrations +4. Update documentation in `components.md` + +--- + +## 📝 License + +This project is built using the Tambo template. Check individual dependencies for their licenses. + +--- + +## 🆘 Support + +- **Tambo Documentation**: [docs.tambo.co](https://docs.tambo.co) +- **Tambo Dashboard**: [tambo.co/dashboard](https://tambo.co/dashboard) +- **Next.js Documentation**: [nextjs.org/docs](https://nextjs.org/docs) + +--- + +## 🚀 What's Next? + +IndentOS is a foundation for building intent-driven applications. You can: + +- **Add more workflows** for different use cases (travel planning, task management, etc.) +- **Integrate with external APIs** to expand capabilities +- **Create custom components** for your specific domain +- **Build on the authentication** to add user profiles and data persistence +- **Deploy to production** using Vercel, Netlify, or your preferred host -For more detailed documentation, visit [Tambo's official docs](https://docs.tambo.co). +**Start building the future of human-computer interaction with IndentOS!** 🎉 diff --git a/example.env.local b/example.env.local index 3c921f9..c8e4111 100644 --- a/example.env.local +++ b/example.env.local @@ -1,3 +1,3 @@ -NEXT_PUBLIC_TAMBO_API_KEY=api-key-here +TAMBO_API_KEY=api-key-here NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key diff --git a/package-lock.json b/package-lock.json index bc66753..87d383f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "eslint": "^9.39.2", "eslint-config-next": "^16.0.4", "postcss": "^8.5.6", + "supabase": "^2.76.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4", "typescript": "^5" @@ -1075,6 +1076,19 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -4540,6 +4554,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4934,6 +4958,23 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bin-links": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", + "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -5195,6 +5236,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5222,6 +5273,16 @@ "node": ">=6" } }, + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -5530,6 +5591,16 @@ "node": ">=4" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6728,6 +6799,30 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6825,6 +6920,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7396,6 +7504,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -9554,6 +9676,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/motion-dom": { "version": "12.23.23", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", @@ -9717,6 +9862,46 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -9734,6 +9919,16 @@ "node": ">=0.10.0" } }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10130,6 +10325,16 @@ "node": ">= 0.8.0" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -10737,6 +10942,16 @@ } } }, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/recharts": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.0.tgz", @@ -11432,6 +11647,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -11728,6 +11956,26 @@ "tslib": "^2.8.1" } }, + "node_modules/supabase": { + "version": "2.76.3", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.76.3.tgz", + "integrity": "sha512-xJLyTiPo0WfBwHvNeLcDhyV+A0qyo/VfzL0lijXbvPL0QCY5+aLoiSwJqLempMynMvhq4Hl9EGL00B2LYmvpmQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bin-links": "^6.0.0", + "https-proxy-agent": "^7.0.2", + "node-fetch": "^3.3.2", + "tar": "7.5.7" + }, + "bin": { + "supabase": "bin/supabase" + }, + "engines": { + "npm": ">=8" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11796,6 +12044,33 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -12486,6 +12761,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12616,6 +12901,20 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", + "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/package.json b/package.json index b2f4fb2..7bf17c6 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "eslint": "^9.39.2", "eslint-config-next": "^16.0.4", "postcss": "^8.5.6", + "supabase": "^2.76.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4", "typescript": "^5" diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts new file mode 100644 index 0000000..5e459a9 --- /dev/null +++ b/src/app/api/tambo/[...path]/route.ts @@ -0,0 +1,968 @@ +import { createSupabaseServerClient } from "@/app/supabase/server"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const PROJECT_ID = "supabase"; +const MISSING_TAMBO_API_KEY_ERROR = + "Missing TAMBO_API_KEY. Set it in .env.local (server-side, not NEXT_PUBLIC)."; + +type ThreadRow = { + id: string; + created_at: string; + updated_at: string; + name: string | null; + metadata: Record | null; +}; + +type MessageRow = { + id: string; + thread_id: string; + role: "user" | "assistant" | "system" | "tool"; + content: unknown; + component_state: Record | null; + additional_context: Record | null; + component: Record | null; + tool_call_request: Record | null; + tool_calls: unknown[] | null; + tool_call_id: string | null; + parent_message_id: string | null; + reasoning: unknown; + reasoning_duration_ms: number | null; + error: string | null; + is_cancelled: boolean | null; + metadata: Record | null; + created_at: string; +}; + +const MESSAGE_SELECT_COLUMNS = [ + "id", + "thread_id", + "role", + "content", + "component_state", + "additional_context", + "component", + "tool_call_request", + "tool_calls", + "tool_call_id", + "parent_message_id", + "reasoning", + "reasoning_duration_ms", + "error", + "is_cancelled", + "metadata", + "created_at", +].join(","); + +function getTamboBaseUrl(): string { + return process.env.TAMBO_URL ?? "https://api.tambo.co"; +} + +function getTamboApiKey(): string | null { + return process.env.TAMBO_API_KEY ?? null; +} + +function jsonError(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +function threadFromRow(row: ThreadRow, userId: string) { + return { + id: row.id, + createdAt: row.created_at, + updatedAt: row.updated_at, + projectId: PROJECT_ID, + name: row.name ?? undefined, + metadata: row.metadata ?? undefined, + contextKey: userId, + generationStage: "IDLE", + statusMessage: "", + }; +} + +function messageFromRow(row: MessageRow) { + return { + id: row.id, + threadId: row.thread_id, + role: row.role, + content: row.content, + createdAt: row.created_at, + componentState: row.component_state ?? {}, + additionalContext: row.additional_context ?? undefined, + component: row.component ?? undefined, + toolCallRequest: row.tool_call_request ?? undefined, + tool_calls: row.tool_calls ?? undefined, + tool_call_id: row.tool_call_id ?? undefined, + parentMessageId: row.parent_message_id ?? undefined, + reasoning: row.reasoning ?? undefined, + reasoningDurationMS: row.reasoning_duration_ms ?? undefined, + error: row.error ?? undefined, + isCancelled: row.is_cancelled ?? undefined, + metadata: row.metadata ?? undefined, + }; +} + +async function selectMessagesForThread( + supabase: Awaited>, + threadId: string, +) { + return supabase + .from("messages") + .select(MESSAGE_SELECT_COLUMNS) + .eq("thread_id", threadId) + .order("created_at", { ascending: true }); +} + +async function tamboSseFetch(pathname: string, init: RequestInit) { + const apiKey = getTamboApiKey(); + if (!apiKey) { + throw new Error(MISSING_TAMBO_API_KEY_ERROR); + } + + const url = new URL(pathname, getTamboBaseUrl()); + const headers = new Headers(init.headers); + headers.set("x-api-key", apiKey); + headers.set("accept", "text/event-stream"); + + return fetch(url, { + ...init, + headers, + }); +} + +function getFirstUserMessageText(messages: Array<{ role: string; content: any }>) { + type TextPart = { type: "text"; text: string }; + const isTextPart = (value: unknown): value is TextPart => { + return ( + typeof value === "object" && + value !== null && + (value as any).type === "text" && + typeof (value as any).text === "string" + ); + }; + + for (const msg of messages) { + if (msg.role !== "user") continue; + const text = Array.isArray(msg.content) + ? msg.content + .filter(isTextPart) + .map((part) => part.text) + .filter(Boolean) + .join(" ") + .trim() + : ""; + if (text) return text; + } + return null; +} + +async function handleThreadsList( + supabase: Awaited>, + userId: string, +) { + const { data, error } = await supabase + .from("threads") + .select("id, created_at, updated_at, name, metadata") + .eq("user_id", userId) + .order("updated_at", { ascending: false }); + + if (error) { + return jsonError(error.message, 500); + } + + const items = (data as unknown as ThreadRow[]).map((row) => + threadFromRow(row, userId), + ); + + return NextResponse.json({ + items, + total: items.length, + count: items.length, + }); +} + +async function handleThreadRetrieve( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id, created_at, updated_at, name, metadata") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const { data: messages, error: msgError } = + await selectMessagesForThread(supabase, threadId); + + if (msgError) return jsonError(msgError.message, 500); + + return NextResponse.json({ + ...threadFromRow(thread as unknown as ThreadRow, userId), + messages: (messages as unknown as MessageRow[]).map(messageFromRow), + }); +} + +async function handleThreadUpdate( + request: Request, + supabase: Awaited>, + userId: string, + threadId: string, +) { + const body = (await request.json().catch(() => null)) as + | { name?: string; metadata?: Record } + | null; + + if (!body) return jsonError("Invalid JSON body", 400); + + const update: Record = {}; + if (typeof body.name === "string") update.name = body.name; + if (body.metadata && typeof body.metadata === "object") { + update.metadata = body.metadata; + } + + if (Object.keys(update).length === 0) { + return jsonError("No valid fields to update", 400); + } + + const { error } = await supabase + .from("threads") + .update(update) + .eq("id", threadId) + .eq("user_id", userId); + + if (error) return jsonError(error.message, 500); + + const { data: thread, error: readError } = await supabase + .from("threads") + .select("id, created_at, updated_at, name, metadata") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (readError) return jsonError(readError.message, 500); + if (!thread) return jsonError("Not found", 404); + + return NextResponse.json(threadFromRow(thread as unknown as ThreadRow, userId)); +} + +async function handleThreadGenerateName( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const { data: messages, error } = await supabase + .from("messages") + .select("role, content") + .eq("thread_id", threadId) + .order("created_at", { ascending: true }); + + if (error) return jsonError(error.message, 500); + + const seed = getFirstUserMessageText(messages as any); + const name = seed + ? seed.slice(0, 48) + (seed.length > 48 ? "…" : "") + : `Thread ${threadId.slice(0, 8)}`; + + const { error: updateError } = await supabase + .from("threads") + .update({ name }) + .eq("id", threadId) + .eq("user_id", userId); + + if (updateError) return jsonError(updateError.message, 500); + + const { data: updatedThread, error: readError } = await supabase + .from("threads") + .select("id, created_at, updated_at, name, metadata") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (readError) return jsonError(readError.message, 500); + if (!updatedThread) return jsonError("Not found", 404); + + return NextResponse.json( + threadFromRow(updatedThread as unknown as ThreadRow, userId), + ); +} + +async function handleThreadCancel( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (error) return jsonError(error.message, 500); + if (!thread) return jsonError("Not found", 404); + + return NextResponse.json(true); +} + +async function handleThreadDelete( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { error } = await supabase + .from("threads") + .delete() + .eq("id", threadId) + .eq("user_id", userId); + + if (error) return jsonError(error.message, 500); + + return new Response(null, { status: 204 }); +} + +async function handleThreadMessagesList( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const { data: messages, error } = await selectMessagesForThread( + supabase, + threadId, + ); + + if (error) return jsonError(error.message, 500); + + return NextResponse.json( + (messages as unknown as MessageRow[]).map(messageFromRow), + ); +} + +async function handleThreadMessagesCreate( + request: Request, + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const body = (await request.json().catch(() => null)) as + | { + role?: "user" | "assistant" | "system" | "tool"; + content?: unknown; + additionalContext?: Record; + component?: Record; + componentState?: Record; + toolCallRequest?: Record; + tool_calls?: unknown[]; + tool_call_id?: string; + parentMessageId?: string; + reasoning?: unknown; + reasoningDurationMS?: number; + error?: string; + isCancelled?: boolean; + metadata?: Record; + } + | null; + + if (!body) return jsonError("Invalid JSON body", 400); + if (!body.role) return jsonError("Message role is required", 400); + if (body.content == null) return jsonError("Message content is required", 400); + + const allowedRoles = new Set(["user", "assistant", "system", "tool"]); + if (!allowedRoles.has(body.role)) { + return jsonError("Invalid message role", 400); + } + + const id = crypto.randomUUID(); + const { error } = await supabase.from("messages").insert({ + id, + thread_id: threadId, + role: body.role, + content: body.content, + additional_context: body.additionalContext ?? null, + component_state: body.componentState ?? {}, + component: body.component ?? null, + tool_call_request: body.toolCallRequest ?? null, + tool_calls: body.tool_calls ?? null, + tool_call_id: body.tool_call_id ?? null, + parent_message_id: body.parentMessageId ?? null, + reasoning: body.reasoning ?? null, + reasoning_duration_ms: body.reasoningDurationMS ?? null, + error: body.error ?? null, + is_cancelled: body.isCancelled ?? false, + metadata: body.metadata ?? null, + }); + + if (error) return jsonError(error.message, 500); + + const { error: threadUpdateError } = await supabase + .from("threads") + .update({ updated_at: new Date().toISOString() }) + .eq("id", threadId) + .eq("user_id", userId); + + if (threadUpdateError) return jsonError(threadUpdateError.message, 500); + + return NextResponse.json({ id }); +} + +async function handleThreadMessageUpdateComponentState( + request: Request, + supabase: Awaited>, + userId: string, + threadId: string, + messageId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const body = (await request.json().catch(() => null)) as + | { state?: Record } + | null; + if (!body || !body.state || typeof body.state !== "object") { + return jsonError("Invalid JSON body", 400); + } + + const { data: message, error: readError } = await supabase + .from("messages") + .select("component_state") + .eq("id", messageId) + .eq("thread_id", threadId) + .maybeSingle(); + + if (readError) return jsonError(readError.message, 500); + if (!message) return jsonError("Not found", 404); + + const current = + message.component_state && typeof message.component_state === "object" + ? (message.component_state as Record) + : {}; + + // Shallow merge (top-level keys) to match `useTamboComponentState` updates. + const next = { ...current, ...body.state }; + + const { data: updated, error } = await supabase + .from("messages") + .update({ component_state: next }) + .eq("id", messageId) + .eq("thread_id", threadId) + .select(MESSAGE_SELECT_COLUMNS) + .maybeSingle(); + + if (error) return jsonError(error.message, 500); + if (!updated) return jsonError("Not found", 404); + + return NextResponse.json(messageFromRow(updated as unknown as MessageRow)); +} + +async function handleAdvanceStream( + request: Request, + supabase: Awaited>, + userId: string, + threadId: string | null, +) { + type AdvanceStreamRequestBody = { + messageToAppend?: { + role: "user" | "assistant" | "system" | "tool"; + content: unknown; + additionalContext?: Record; + component?: Record; + toolCallRequest?: Record; + }; + initialMessages?: unknown; + availableComponents?: unknown; + forceToolChoice?: unknown; + toolCallCounts?: unknown; + }; + + const body = (await request.json().catch(() => null)) as + | AdvanceStreamRequestBody + | null; + if (!body || !body.messageToAppend) { + return jsonError("Invalid JSON body", 400); + } + + const messageToAppend = body.messageToAppend as { + role: "user" | "assistant" | "system" | "tool"; + content: unknown; + additionalContext?: Record; + component?: Record; + toolCallRequest?: Record; + }; + + const allowedRoles = new Set(["user", "assistant", "system", "tool"]); + if (!allowedRoles.has(messageToAppend.role)) { + return jsonError("Invalid message role", 400); + } + if (messageToAppend.content == null) { + return jsonError("Message content is required", 400); + } + + let persistentThreadId = threadId; + + if (persistentThreadId) { + const { data: thread, error } = await supabase + .from("threads") + .select("id") + .eq("id", persistentThreadId) + .eq("user_id", userId) + .maybeSingle(); + + if (error) return jsonError(error.message, 500); + if (!thread) return jsonError("Not found", 404); + } + + if (!persistentThreadId) { + const { data: newThread, error: threadError } = await supabase + .from("threads") + .insert({ user_id: userId }) + .select("id") + .single(); + + if (threadError) return jsonError(threadError.message, 500); + persistentThreadId = (newThread as any).id as string; + + const initial = Array.isArray(body.initialMessages) + ? body.initialMessages + : []; + if (initial.length > 0) { + const initialRows = initial.map((m: any) => ({ + id: crypto.randomUUID(), + thread_id: persistentThreadId, + role: m.role, + content: m.content, + additional_context: m.additionalContext ?? null, + component_state: m.componentState ?? {}, + component: m.component ?? null, + tool_call_request: m.toolCallRequest ?? null, + })); + + const { error: insertError } = await supabase.from("messages").insert(initialRows); + if (insertError) return jsonError(insertError.message, 500); + } + } + + const { data: historyRows, error: historyError } = await supabase + .from("messages") + .select( + [ + "role", + "content", + "additional_context", + "component", + "tool_call_request", + "created_at", + ].join(","), + ) + .eq("thread_id", persistentThreadId) + .order("created_at", { ascending: true }); + + if (historyError) return jsonError(historyError.message, 500); + + const { error: appendError } = await supabase.from("messages").insert({ + id: crypto.randomUUID(), + thread_id: persistentThreadId, + role: messageToAppend.role, + content: messageToAppend.content, + additional_context: messageToAppend.additionalContext ?? null, + component_state: {}, + component: messageToAppend.component ?? null, + tool_call_request: messageToAppend.toolCallRequest ?? null, + }); + + if (appendError) return jsonError(appendError.message, 500); + + const initialMessages = (historyRows as any[]).map((m) => ({ + role: m.role, + content: m.content, + additionalContext: m.additional_context ?? undefined, + component: m.component ?? undefined, + toolCallRequest: m.tool_call_request ?? undefined, + })); + + const computeBody: Record = { + contextKey: userId, + initialMessages, + messageToAppend, + clientTools: [], + }; + + if (body.availableComponents != null) { + computeBody.availableComponents = body.availableComponents; + } + if (typeof body.forceToolChoice === "string") { + computeBody.forceToolChoice = body.forceToolChoice; + } + if (body.toolCallCounts && typeof body.toolCallCounts === "object") { + computeBody.toolCallCounts = body.toolCallCounts; + } + + const tamboResponse = await tamboSseFetch("/threads/advancestream", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(computeBody), + signal: request.signal, + }); + + if (!tamboResponse.ok || !tamboResponse.body) { + const text = await tamboResponse.text().catch(() => ""); + return jsonError(text || "Tambo request failed", tamboResponse.status); + } + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const messageIdMap = new Map(); + const finalMessages = new Map(); + + let didPersist = false; + const persistMessages = async () => { + if (didPersist) return; + didPersist = true; + + if (finalMessages.size > 0) { + const rows = Array.from(finalMessages.values()).map((m) => ({ + id: m.id, + thread_id: persistentThreadId, + role: m.role, + content: m.content, + component_state: m.componentState ?? {}, + additional_context: m.additionalContext ?? null, + component: m.component ?? null, + tool_call_request: m.toolCallRequest ?? null, + tool_calls: m.tool_calls ?? null, + tool_call_id: m.tool_call_id ?? null, + parent_message_id: m.parentMessageId ?? null, + reasoning: m.reasoning ?? null, + reasoning_duration_ms: m.reasoningDurationMS ?? null, + error: m.error ?? null, + is_cancelled: m.isCancelled ?? false, + metadata: m.metadata ?? null, + })); + + const { error } = await supabase.from("messages").upsert(rows); + if (error) { + throw new Error(error.message); + } + } + + const { error: threadUpdateError } = await supabase + .from("threads") + .update({ updated_at: new Date().toISOString() }) + .eq("id", persistentThreadId) + .eq("user_id", userId); + + if (threadUpdateError) { + throw new Error(threadUpdateError.message); + } + }; + + let buffer = ""; + const reader = (tamboResponse.body as ReadableStream).getReader(); + + let pendingDone = false; + + const stream = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + try { + await persistMessages(); + if (pendingDone) { + controller.enqueue(encoder.encode("data: DONE\n")); + } + } catch (error) { + console.error("Failed to persist streamed messages", { + error, + userId, + threadId: persistentThreadId, + messageCount: finalMessages.size, + }); + + controller.enqueue( + encoder.encode( + "error: Failed to persist conversation state, some messages may be missing.\n", + ), + ); + } + controller.close(); + return; + } + + buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n"); + + while (true) { + const nl = buffer.indexOf("\n"); + if (nl === -1) break; + + const rawLine = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + + if (!rawLine) continue; + if (rawLine === "data: DONE") { + pendingDone = true; + continue; + } + if (rawLine.startsWith("error: ")) { + controller.enqueue(encoder.encode(`${rawLine}\n`)); + continue; + } + + const jsonStr = rawLine.startsWith("data: ") ? rawLine.slice(6) : rawLine; + if (!jsonStr) continue; + + let chunk: any; + try { + chunk = JSON.parse(jsonStr); + } catch { + continue; + } + + const dto = chunk?.responseMessageDto; + if (dto && typeof dto === "object") { + const originalMessageId = typeof dto.id === "string" ? dto.id : null; + if (originalMessageId) { + const mapped = messageIdMap.get(originalMessageId) ?? crypto.randomUUID(); + messageIdMap.set(originalMessageId, mapped); + dto.id = mapped; + + finalMessages.set(mapped, { + ...dto, + threadId: persistentThreadId, + }); + } + + dto.threadId = persistentThreadId; + } + + const outLine = `data: ${JSON.stringify(chunk)}\n`; + controller.enqueue(encoder.encode(outLine)); + } + }, + + async cancel() { + try { + await persistMessages(); + } catch (error) { + console.error("Failed to persist streamed messages", { + error, + userId, + threadId: persistentThreadId, + messageCount: finalMessages.size, + }); + } + reader.cancel().catch(() => undefined); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }, + }); +} + +async function proxyToTambo(request: Request, path: string[]) { + const apiKey = getTamboApiKey(); + if (!apiKey) { + return jsonError(MISSING_TAMBO_API_KEY_ERROR, 500); + } + + const targetUrl = new URL(`/${path.join("/")}`, getTamboBaseUrl()); + const requestUrl = new URL(request.url); + targetUrl.search = requestUrl.search; + + const headers = new Headers(request.headers); + headers.set("x-api-key", apiKey); + headers.delete("host"); + headers.delete("content-length"); + + const body = request.body ? request.clone().body : undefined; + + const response = await fetch(targetUrl, { + method: request.method, + headers, + body, + redirect: "manual", + }); + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-encoding"); + + return new Response(response.body, { + status: response.status, + headers: responseHeaders, + }); +} + +async function handler( + request: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return jsonError("Unauthorized", 401); + } + + const { path } = await params; + + if (path.length === 1 && path[0] === "projects" && request.method === "GET") { + return NextResponse.json({ + id: PROJECT_ID, + isTokenRequired: false, + name: "IntentOS", + providerType: "llm", + userId: user.id, + }); + } + + if (path[0] === "threads") { + if (path.length >= 3 && path[1] === "project" && request.method === "GET") { + return handleThreadsList(supabase, user.id); + } + + if (path.length === 2 && path[1] === "advancestream" && request.method === "POST") { + return handleAdvanceStream(request, supabase, user.id, null); + } + + if (path.length >= 2) { + const threadId = path[1]; + + if (path.length === 3 && path[2] === "messages" && request.method === "GET") { + return handleThreadMessagesList(supabase, user.id, threadId); + } + + if (path.length === 3 && path[2] === "messages" && request.method === "POST") { + return handleThreadMessagesCreate(request, supabase, user.id, threadId); + } + + if ( + path.length === 5 && + path[2] === "messages" && + path[4] === "component-state" && + request.method === "PUT" + ) { + const messageId = path[3]; + return handleThreadMessageUpdateComponentState( + request, + supabase, + user.id, + threadId, + messageId, + ); + } + + if (path.length === 2 && request.method === "GET") { + return handleThreadRetrieve(supabase, user.id, threadId); + } + + if (path.length === 2 && request.method === "PUT") { + return handleThreadUpdate(request, supabase, user.id, threadId); + } + + if (path.length === 3 && path[2] === "generate-name" && request.method === "POST") { + return handleThreadGenerateName(supabase, user.id, threadId); + } + + if (path.length === 3 && path[2] === "cancel" && request.method === "POST") { + return handleThreadCancel(supabase, user.id, threadId); + } + + if (path.length === 3 && path[2] === "advancestream" && request.method === "POST") { + return handleAdvanceStream(request, supabase, user.id, threadId); + } + + if (path.length === 2 && request.method === "DELETE") { + return handleThreadDelete(supabase, user.id, threadId); + } + } + + return jsonError("Not found", 404); + } + + return proxyToTambo(request, path); +} + +export async function GET( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} + +export async function POST( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} + +export async function PUT( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} + +export async function PATCH( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} + +export async function DELETE( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} diff --git a/src/app/auth/actions.ts b/src/app/auth/actions.ts index 4baea66..39db555 100644 --- a/src/app/auth/actions.ts +++ b/src/app/auth/actions.ts @@ -1,6 +1,6 @@ "use server"; -import { createSupabaseServerActionClient } from "@/lib/supabase/server"; +import { createSupabaseServerActionClient } from "@/app/supabase/server"; import { redirect } from "next/navigation"; import { z } from "zod"; diff --git a/src/app/chat/chat-client.tsx b/src/app/chat/chat-client.tsx new file mode 100644 index 0000000..8c27017 --- /dev/null +++ b/src/app/chat/chat-client.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { MessageThreadFull } from "@/components/tambo/message-thread-full"; +import { useMcpServers } from "@/components/tambo/mcp-config-modal"; +import { components, tools } from "@/lib/tambo"; +import { TamboProvider } from "@tambo-ai/react"; + +export function ChatClient({ userId }: { userId: string }) { + const mcpServers = useMcpServers(); + const apiKey = process.env.NEXT_PUBLIC_TAMBO_API_KEY; + return ( + +
+ +
+
+ ); +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 613c176..1d037a7 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,27 +1,22 @@ -"use client"; +// "use client"; -import { MessageThreadFull } from "@/components/tambo/message-thread-full"; -import { useMcpServers } from "@/components/tambo/mcp-config-modal"; -import { GEMINI_INTENT_SYSTEM_PROMPT } from "@/lib/intent/gemini-intent-system-prompt"; -import { components, tools } from "@/lib/tambo"; -import { TamboProvider } from "@tambo-ai/react"; +import { ChatClient } from "@/app/chat/chat-client"; +import { createSupabaseServerClient } from "@/app/supabase/server"; +import { redirect } from "next/navigation"; -/** - * Home page component that renders the Tambo chat interface. - * - * @remarks - * The `NEXT_PUBLIC_TAMBO_URL` environment variable specifies the URL of the Tambo server. - * You do not need to set it if you are using the default Tambo server. - * It is only required if you are running the API server locally. - * - * @see {@link https://github.com/tambo-ai/tambo/blob/main/CONTRIBUTING.md} for instructions on running the API server locally. - */ -export default function Home() { - // Load MCP server configurations - const mcpServers = useMcpServers(); - const apiKey = process.env.NEXT_PUBLIC_TAMBO_API_KEY; +export default async function ChatPage() { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); - if (!apiKey) { + if (!user) { + redirect("/login"); + } + + const hasTamboApiKey = !!process.env.NEXT_PUBLIC_TAMBO_API_KEY; + + if (!hasTamboApiKey) { return (
@@ -29,32 +24,27 @@ export default function Home() { Missing Tambo API key
- Set NEXT_PUBLIC_TAMBO_API_KEY in - your environment to use the chat. + Set TAMBO_API_KEY in your + environment to use the chat.
); } - return ( - -
- -
-
- ); + // return ( + // + //
+ // + //
+ //
+ // ); + return } diff --git a/src/app/interactables/page.tsx b/src/app/interactables/page.tsx index 6bf50be..8a73488 100644 --- a/src/app/interactables/page.tsx +++ b/src/app/interactables/page.tsx @@ -16,16 +16,21 @@ import { TamboProvider } from "@tambo-ai/react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useState } from "react"; import { SettingsPanel } from "./components/settings-panel"; +import { useMcpServers } from "@/components/tambo/mcp-config-modal"; export default function InteractablesPage() { const [isChatOpen, setIsChatOpen] = useState(true); + const mcpServers = useMcpServers(); + const apiKey = process.env.NEXT_PUBLIC_TAMBO_API_KEY; return (
{/* Chat Sidebar */} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c6df6b4..1560936 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,7 +1,7 @@ import Footer from "@/components/Footer"; import Navbar from "@/components/Navbar"; import { signIn } from "@/app/auth/actions"; -import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { createSupabaseServerClient } from "@/app/supabase/server"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -30,77 +30,82 @@ export default async function LoginPage({ const message = getFirst(sp.message); return ( -
- -
-

Sign in

-

- Log in with your email and password. -

+
+ - {error && ( -
- {error} -
- )} +
+
+

Sign in

+

+ Log in with your email and password. +

- {!error && message && ( -
- {message} -
- )} + {error && ( +
+ {error} +
+ )} -
-
- - -
+ {!error && message && ( +
+ {message} +
+ )} -
- - -
+ +
+ + +
- -
+ Password + + +
+ + + + +

+ Don't have an account?{" "} + + Create one + +

+
+
+ +
+
-

- Don't have an account?{" "} - - Create one - -

-
-