diff --git a/README.md b/README.md index 7d3bd1d..63fb2e3 100644 --- a/README.md +++ b/README.md @@ -1,285 +1,317 @@ -# 🎨 ATXP Cloudflare Agent - AI Image Generation Demo +# 🤖 Chat Agent Starter Kit ![npm i agents command](./npm-agents-banner.svg) -Deploy to Cloudflare +Deploy to Cloudflare -A demo implementation showing how to integrate [ATXP](https://docs.atxp.ai) with Cloudflare Agents for AI-powered image generation. This project demonstrates text-to-image generation using ATXP's Image MCP server, with real-time progress updates and file storage capabilities. +A starter template for building AI-powered chat agents using Cloudflare's Agent platform, powered by [`agents`](https://www.npmjs.com/package/agents). This project provides a foundation for creating interactive chat experiences with AI, complete with a modern UI and tool integration capabilities. ## Features -- 🎨 **AI Image Generation**: Generate images from text prompts using ATXP Image MCP server -- 💬 **Interactive Chat Interface**: Modern chat UI for natural conversations -- ⚡️ **Real-time Progress Updates**: WebSocket-based progress tracking during image generation -- 📁 **File Storage**: Automatic storage of generated images using ATXP Filestore -- 🔄 **Async Processing**: Background polling for long-running image generation tasks -- 💳 **Payment Tracking**: Real-time display of ATXP payment information -- 📋 **Task Management**: List, check status, and manage image generation tasks -- 🌓 **Dark/Light Theme**: Modern, responsive UI with theme support +- 💬 Interactive chat interface with AI +- 🛠️ Built-in tool system with human-in-the-loop confirmation +- 📅 Advanced task scheduling (one-time, delayed, and recurring via cron) +- 🎨 **AI Image Generation** - Create images from text prompts with automatic completion notifications +- 🌓 Dark/Light theme support +- ⚡️ Real-time streaming responses +- 🔄 State management and chat history +- 🖼️ Modern, responsive UI with inline image display ## Prerequisites - Cloudflare account - OpenAI API key -- ATXP connection string (get from [ATXP Console](https://console.atxp.ai)) +- ATXP account (optional, for AI image generation features) - Get one at [accounts.atxp.ai](https://accounts.atxp.ai) ## Quick Start -1. **Clone this repository:** +1. Create a new project: ```bash -git clone https://github.com/atxp-dev/atxp-cloudflare-agent-example.git -cd atxp-cloudflare-agent-example +npx create-cloudflare@latest --template cloudflare/agents-starter ``` -2. **Install dependencies:** +2. Install dependencies: ```bash npm install ``` -3. **Set up your environment:** +3. Set up your environment: -Create a `.dev.vars` file from the example: - -```bash -cp .dev.vars.example .dev.vars -``` - -Edit `.dev.vars` and add your API keys: +Create a `.dev.vars` file: ```env OPENAI_API_KEY=your_openai_api_key - -# Optional - ATXP connection string (URL format) -# If not provided, users can provide it when generating images -# Get your connection string from https://console.atxp.ai -ATXP_CONNECTION_STRING=https://accounts.atxp.ai?connection_token=your_connection_token_here +ATXP_CONNECTION_STRING=https://accounts.atxp.ai?connection_token=your_connection_token ``` -4. **Run locally:** +**Note:** The ATXP_CONNECTION_STRING is optional and only needed if you want to use the AI image generation features. You can also provide connection strings dynamically through the chat interface. + +4. Run locally: ```bash npm start ``` -5. **Deploy to Cloudflare:** +5. Deploy: ```bash npm run deploy ``` -## Usage - -### Basic Image Generation - -Once the agent is running, you can generate images by chatting with the AI: - -- "Generate an image of a sunset over mountains" -- "Create a picture of a futuristic city" -- "Draw a cat wearing a space helmet" - -### Advanced Features - -The agent provides several tools for managing image generation: - -1. **generateImage** - Creates images from text prompts -2. **getImageGenerationStatus** - Checks the status of specific tasks -3. **listImageGenerationTasks** - Shows all image generation tasks - -### ATXP Connection String - -You can provide your ATXP connection string in two ways: - -1. **Environment Variable** (recommended for single-user deployments): - - ```env - ATXP_CONNECTION_STRING=https://accounts.atxp.ai?connection_token=your_token_here - ``` - -2. **Runtime Parameter** (recommended for multi-user scenarios): - ``` - Generate an image of a dragon with connection string https://accounts.atxp.ai?connection_token=ABC123DEF456 - ``` - ## Project Structure ``` ├── src/ -│ ├── app.tsx # Chat UI implementation -│ ├── server.ts # Chat agent with ATXP integration -│ ├── tools.ts # Tool definitions (includes ATXP tools) +│ ├── app.tsx # Chat UI implementation +│ ├── server.ts # Chat agent logic with image polling +│ ├── tools.ts # Basic tool definitions │ ├── tools/ -│ │ └── imageGeneration.ts # ATXP image generation tools +│ │ └── imageGeneration.ts # ATXP image generation tools │ ├── utils/ -│ │ └── atxp.ts # ATXP utility functions -│ ├── utils.ts # General helper functions -│ └── styles.css # UI styling -├── .dev.vars.example # Environment variables template -└── wrangler.jsonc # Cloudflare Workers configuration +│ │ └── atxp.ts # ATXP connection utilities +│ ├── utils.ts # Helper functions +│ └── styles.css # UI styling ``` -## How It Works - -### Image Generation Flow - -1. **User Request**: User asks for an image through chat -2. **Tool Execution**: `generateImage` tool is called with the prompt -3. **ATXP Integration**: Creates ATXP Image MCP client and starts async generation -4. **Background Polling**: Agent schedules periodic status checks -5. **Progress Updates**: Real-time WebSocket updates sent to user -6. **File Storage**: Completed images stored in ATXP Filestore -7. **Completion Notification**: Final result delivered to chat - -### Key Components - -- **ATXP Image MCP Server**: Handles AI image generation -- **ATXP Filestore MCP Server**: Stores and manages generated images -- **Cloudflare Durable Objects**: Persistent state management for tasks -- **WebSocket Broadcasting**: Real-time progress updates -- **Scheduled Tasks**: Background polling for async operations - -## ATXP Integration Details - -This demo showcases several ATXP capabilities: - -### Image Generation +## Customization Guide + +### Adding New Tools + +Add new tools in `tools.ts` using the tool builder: + +```ts +// Example of a tool that requires confirmation +const searchDatabase = tool({ + description: "Search the database for user records", + parameters: z.object({ + query: z.string(), + limit: z.number().optional() + }) + // No execute function = requires confirmation +}); + +// Example of an auto-executing tool +const getCurrentTime = tool({ + description: "Get current server time", + parameters: z.object({}), + execute: async () => new Date().toISOString() +}); + +// Scheduling tool implementation +const scheduleTask = tool({ + description: + "schedule a task to be executed at a later time. 'when' can be a date, a delay in seconds, or a cron pattern.", + parameters: z.object({ + type: z.enum(["scheduled", "delayed", "cron"]), + when: z.union([z.number(), z.string()]), + payload: z.string() + }), + execute: async ({ type, when, payload }) => { + // ... see the implementation in tools.ts + } +}); +``` -- Text-to-image generation using advanced AI models -- Async processing with task tracking -- Configurable generation parameters +To handle tool confirmations, add execution functions to the `executions` object: + +```typescript +export const executions = { + searchDatabase: async ({ + query, + limit + }: { + query: string; + limit?: number; + }) => { + // Implementation for when the tool is confirmed + const results = await db.search(query, limit); + return results; + } + // Add more execution handlers for other tools that require confirmation +}; +``` -### File Storage +Tools can be configured in two ways: -- Automatic storage of generated images -- Public URL generation for easy sharing -- Metadata tracking and file management +1. With an `execute` function for automatic execution +2. Without an `execute` function, requiring confirmation and using the `executions` object to handle the confirmed action. NOTE: The keys in `executions` should match `toolsRequiringConfirmation` in `app.tsx`. -### Payment Tracking +### AI Image Generation with ATXP -- Real-time payment notifications -- Transparent cost tracking -- Multi-network support (Ethereum, Polygon, etc.) +This project includes advanced AI image generation capabilities powered by ATXP (AI Transaction Protocol). The image generation system provides: -### Account Management +- **Automatic background processing** - Images generate asynchronously while you continue chatting +- **Real-time status updates** - Get notified when generation starts and completes +- **Inline image display** - Generated images appear directly in the chat +- **Payment notifications** - Receive chat messages when payments are processed for image generation +- **Task management** - View all your image generation tasks and their status -- Secure connection string handling -- Multi-tenant support -- Network and currency configuration +#### Setting up Image Generation -## Deployment +1. **Get an ATXP account** at [accounts.atxp.ai](https://accounts.atxp.ai) +2. **Copy your connection string** in the format: `https://accounts.atxp.ai?connection_token=your_token` +3. **Add it to your environment**: + ```env + ATXP_CONNECTION_STRING=https://accounts.atxp.ai?connection_token=your_token + ``` +4. **Deploy your changes** with `wrangler secret put ATXP_CONNECTION_STRING` -### Cloudflare Workers +#### Using Image Generation -1. **Set up secrets:** +Simply ask the AI to generate images: -```bash -# Copy your environment variables to Cloudflare -wrangler secret bulk .dev.vars ``` - -2. **Deploy:** - -```bash -npm run deploy +"Generate an image of a sunset over mountains" +"Create a logo for a coffee shop" +"Make a picture of a robot playing chess" ``` -3. **Configure custom domain (optional):** +The system will: -Add a custom domain in the Cloudflare Workers dashboard for production use. +1. Start the image generation task +2. Show you the task ID and status +3. Notify you with a chat message when payment is processed +4. Poll for completion automatically every 10 seconds +5. Notify you when complete with the image displayed inline +6. Handle any errors gracefully -### Environment Variables +#### Available Image Commands -For production deployment, set these environment variables: +- **Generate images**: "Generate an image of..." or "Create a picture of..." +- **Check status**: "Check image status" (shows all tasks) +- **List tasks**: "List my image generation tasks" -- `OPENAI_API_KEY` - Your OpenAI API key -- `ATXP_CONNECTION_STRING` - Your ATXP connection string (optional) +#### Connection String Management -## Development +You can provide ATXP connection strings in multiple ways: -### Adding New Image Generation Features +1. **Environment variable** (recommended for production): -You can extend the image generation capabilities by: + ```env + ATXP_CONNECTION_STRING=https://accounts.atxp.ai?connection_token=your_token + ``` -1. **Adding new tools** in `src/tools/imageGeneration.ts` -2. **Modifying generation parameters** in the ATXP client configuration -3. **Customizing progress updates** in the polling logic -4. **Adding image processing features** using additional ATXP services +2. **Dynamic in chat**: Provide the connection string when generating images: + ``` + "Generate an image of a cat using https://accounts.atxp.ai?connection_token=your_token" + ``` -### Customizing the UI +The system prioritizes dynamically provided connection strings over environment variables. -The chat interface can be customized in `src/app.tsx`: +### Use a different AI model provider -- Modify progress display components -- Add image preview functionality -- Customize payment information display -- Add task management UI elements +The starting [`server.ts`](https://github.com/cloudflare/agents-starter/blob/main/src/server.ts) implementation uses the [`ai-sdk`](https://sdk.vercel.ai/docs/introduction) and the [OpenAI provider](https://sdk.vercel.ai/providers/ai-sdk-providers/openai), but you can use any AI model provider by: -### Error Handling +1. Installing an alternative AI provider for the `ai-sdk`, such as the [`workers-ai-provider`](https://sdk.vercel.ai/providers/community-providers/cloudflare-workers-ai) or [`anthropic`](https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic) provider: +2. Replacing the AI SDK with the [OpenAI SDK](https://github.com/openai/openai-node) +3. Using the Cloudflare [Workers AI + AI Gateway](https://developers.cloudflare.com/ai-gateway/providers/workersai/#workers-binding) binding API directly -The implementation includes comprehensive error handling: +For example, to use the [`workers-ai-provider`](https://sdk.vercel.ai/providers/community-providers/cloudflare-workers-ai), install the package: -- **Connection failures**: Automatic retry logic -- **Payment issues**: Clear error messages and guidance -- **Generation failures**: Status tracking and user notification -- **File storage errors**: Fallback to direct URLs +```sh +npm install workers-ai-provider +``` -## Example Use Cases +Add an `ai` binding to `wrangler.jsonc`: -### Creative Applications +```jsonc +// rest of file + "ai": { + "binding": "AI" + } +// rest of file +``` -- **Art Generation**: Create custom artwork for projects -- **Design Mockups**: Generate design concepts and prototypes -- **Content Creation**: Produce images for blogs, social media, etc. -- **Educational Material**: Create visual aids and illustrations +Replace the `@ai-sdk/openai` import and usage with the `workers-ai-provider`: -### Business Applications +```diff +// server.ts +// Change the imports +- import { openai } from "@ai-sdk/openai"; ++ import { createWorkersAI } from 'workers-ai-provider'; -- **Marketing Assets**: Generate promotional images and graphics -- **Product Visualization**: Create product mockups and concepts -- **Presentation Graphics**: Generate charts, diagrams, and visuals -- **Brand Assets**: Create logos, icons, and brand imagery +// Create a Workers AI instance ++ const workersai = createWorkersAI({ binding: env.AI }); -### Developer Tools +// Use it when calling the streamText method (or other methods) +// from the ai-sdk +- const model = openai("gpt-4o-2024-11-20"); ++ const model = workersai("@cf/deepseek-ai/deepseek-r1-distill-qwen-32b") +``` -- **UI Mockups**: Generate interface concepts and wireframes -- **Documentation Images**: Create technical diagrams and screenshots -- **Testing Assets**: Generate test images for applications -- **Demo Content**: Create sample images for showcases +Commit your changes and then run the `agents-starter` as per the rest of this README. + +### Modifying the UI + +The chat interface is built with React and can be customized in `app.tsx`: + +- Modify the theme colors in `styles.css` +- Add new UI components in the chat container +- Customize message rendering and tool confirmation dialogs +- Add new controls to the header + +### Example Use Cases + +1. **Customer Support Agent** + - Add tools for: + - Ticket creation/lookup + - Order status checking + - Product recommendations + - FAQ database search + +2. **Development Assistant** + - Integrate tools for: + - Code linting + - Git operations + - Documentation search + - Dependency checking + +3. **Data Analysis Assistant** + - Build tools for: + - Database querying + - Data visualization + - Statistical analysis + - Report generation + +4. **Personal Productivity Assistant** + - Implement tools for: + - Task scheduling with flexible timing options + - One-time, delayed, and recurring task management + - Task tracking with reminders + - Email drafting + - Note taking + +5. **Scheduling Assistant** + - Build tools for: + - One-time event scheduling using specific dates + - Delayed task execution (e.g., "remind me in 30 minutes") + - Recurring tasks using cron patterns + - Task payload management + - Flexible scheduling patterns + +6. **Creative Content Assistant** + - Build tools for: + - AI image generation with ATXP integration + - Text content creation and editing + - Visual asset management and storage + - Creative project collaboration + - Automated content workflows + +Each use case can be implemented by: + +1. Adding relevant tools in `tools.ts` +2. Customizing the UI for specific interactions +3. Extending the agent's capabilities in `server.ts` +4. Adding any necessary external API integrations ## Learn More -### ATXP Resources - -- [ATXP Documentation](https://docs.atxp.ai) -- [ATXP Console](https://console.atxp.ai) -- [ATXP Express Example](https://github.com/atxp-dev/atxp-express-example) -- [Image MCP Server Documentation](https://docs.atxp.ai/mcp-servers/image) -- [Filestore MCP Server Documentation](https://docs.atxp.ai/mcp-servers/filestore) - -### Cloudflare Resources - +- [`agents`](https://github.com/cloudflare/agents/blob/main/packages/agents/README.md) - [Cloudflare Agents Documentation](https://developers.cloudflare.com/agents/) - [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/) -- [Durable Objects Documentation](https://developers.cloudflare.com/durable-objects/) - -### Related Examples - -- [ATXP Express Example](https://github.com/atxp-dev/atxp-express-example) - Similar functionality using Express.js -- [ATXP SDK Examples](https://docs.atxp.ai/examples) - Additional integration examples - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Test thoroughly with ATXP services -5. Submit a pull request ## License -MIT License - see [LICENSE](LICENSE) for details. - ---- - -Built with ❤️ using [ATXP](https://atxp.ai) and [Cloudflare Agents](https://developers.cloudflare.com/agents/) +MIT diff --git a/env.d.ts b/env.d.ts index 97eef2f..5fa3952 100644 --- a/env.d.ts +++ b/env.d.ts @@ -4,6 +4,8 @@ declare namespace Cloudflare { interface Env { Chat: DurableObjectNamespace; AI: Ai; + ATXP_CONNECTION_STRING?: string; + OPENAI_API_KEY?: string; } } interface Env extends Cloudflare.Env {} diff --git a/package-lock.json b/package-lock.json index 03203b2..4e0bdbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@ai-sdk/react": "^2.0.30", "@ai-sdk/ui-utils": "^1.2.11", "@atxp/client": "^0.2.22", - "@atxp/common": "^0.2.22", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -2660,26 +2659,26 @@ } }, "node_modules/@expo/cli": { - "version": "54.0.0", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.0.tgz", - "integrity": "sha512-pt+GYt9gXNWmEri5SpIUXHJjD7vZBV6WJFCg7Lie1e62ELcp3aquC6YCCpWfb+89ruDVv2uWWmg6sQXMI9BhLQ==", + "version": "54.0.1", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.1.tgz", + "integrity": "sha512-vIITqb96FCHzCt4ctlkfghAcuNolNpYDE2cqBFt4zqgiesQW19xeucZRiw+IYF0XE0fo3/8OF29H+Aj3aisELA==", "license": "MIT", "peer": true, "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", - "@expo/config": "~12.0.7", + "@expo/config": "~12.0.8", "@expo/config-plugins": "~54.0.0", "@expo/devcert": "^1.1.2", - "@expo/env": "~2.0.6", - "@expo/image-utils": "^0.8.6", - "@expo/json-file": "^10.0.6", + "@expo/env": "~2.0.7", + "@expo/image-utils": "^0.8.7", + "@expo/json-file": "^10.0.7", "@expo/metro": "~0.1.1", - "@expo/metro-config": "~54.0.0", + "@expo/metro-config": "~54.0.1", "@expo/osascript": "^2.3.6", - "@expo/package-manager": "^1.9.6", + "@expo/package-manager": "^1.9.7", "@expo/plist": "^0.4.6", - "@expo/prebuild-config": "^54.0.0", + "@expo/prebuild-config": "^54.0.1", "@expo/schema-utils": "^0.1.6", "@expo/server": "^0.7.3", "@expo/spawn-async": "^1.7.2", @@ -3449,18 +3448,18 @@ } }, "node_modules/@expo/metro-config": { - "version": "54.0.0", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.0.tgz", - "integrity": "sha512-i/1qAVXNaqOPNKBC6QIiiR2V0KsT+QTPxjDbsp8Tm5a6njNwUD9e0LyS84VM1h3SJf8n0uCKFQrQNL5kx5+LRA==", + "version": "54.0.1", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.1.tgz", + "integrity": "sha512-yq+aA38RjmTxFUUWK2xuKWIRAVfnIDf7ephSXcc5isNVGRylwnWE+8N2scWrOQt49ikePbvwWpakTsNfLVLZbw==", "license": "MIT", "peer": true, "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~12.0.7", - "@expo/env": "~2.0.6", - "@expo/json-file": "~10.0.6", + "@expo/config": "~12.0.8", + "@expo/env": "~2.0.7", + "@expo/json-file": "~10.0.7", "@expo/metro": "~0.1.1", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", @@ -3576,13 +3575,13 @@ } }, "node_modules/@expo/package-manager": { - "version": "1.9.6", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.6.tgz", - "integrity": "sha512-oPiMl7wfbC/W5993OqPxwrUda5JU5xqZK5MojVB3cXAxeA2FIevGmVSAROE5UiEra2LatUQuTLjHVe79rBRYVw==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.7.tgz", + "integrity": "sha512-k3uky8Qzlv21rxuPvP2KUTAy8NI0b/LP7BSXcwJpS/rH7RmiAqUXgzPar3I1OmKGgxpod78Y9Mae//F8d3aiOQ==", "license": "MIT", "peer": true, "dependencies": { - "@expo/json-file": "^10.0.6", + "@expo/json-file": "^10.0.7", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", @@ -3649,17 +3648,17 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "54.0.0", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.0.tgz", - "integrity": "sha512-HokcHV/G5f3BlhibFbEVhNZjItvcNTWFYldWku5n3b0QBhPRmevPjIG8/rcDK9CmoHBQk5TtuRfbJwKGP1eAaQ==", + "version": "54.0.1", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.1.tgz", + "integrity": "sha512-f2FFDQlIh83rc89/AOdRBtO9nwkE4BCSji0oPBSq0YmAUSXZE7q/cYKABkArZNjuuwjb6jRjD8JfSMcwV7ybTA==", "license": "MIT", "peer": true, "dependencies": { - "@expo/config": "~12.0.7", + "@expo/config": "~12.0.8", "@expo/config-plugins": "~54.0.0", "@expo/config-types": "^54.0.7", - "@expo/image-utils": "^0.8.6", - "@expo/json-file": "^10.0.6", + "@expo/image-utils": "^0.8.7", + "@expo/json-file": "^10.0.7", "@react-native/normalize-colors": "0.81.4", "debug": "^4.3.1", "resolve-from": "^5.0.0", @@ -9306,9 +9305,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.216", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.216.tgz", - "integrity": "sha512-uVgsufJ+qIiOsZBmqkM2AGPn3gbqPySHl/SLKXJ70nowhI0VsRX4aog+R9EUL2bOjqPPhfR9pG8j8s4Zk4xq+A==", + "version": "1.5.217", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.217.tgz", + "integrity": "sha512-Pludfu5iBxp9XzNl0qq2G87hdD17ZV7h5T4n6rQXDi3nCyloBV3jreE9+8GC6g4X/5yxqVgXEURpcLtM0WS4jA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9623,29 +9622,29 @@ } }, "node_modules/expo": { - "version": "54.0.0", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.0.tgz", - "integrity": "sha512-m3D2xF/uriHTxI+t8Lk8UFr7GZWv+dkmp/ajE1FhYaLMzsxq/IXVJ7gAS31TYmaxnYc82H1lQ02/CvnIZBXw7g==", + "version": "54.0.1", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.1.tgz", + "integrity": "sha512-BJSraR0CM8aUtrSlk8fgOXHhhsB5SeFqc+rJPsnMpdguN60PNkoGF+/ulr9dYfLaPa2w51tW5PmeVTLzESdXbA==", "license": "MIT", "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.0", - "@expo/config": "~12.0.7", + "@expo/cli": "54.0.1", + "@expo/config": "~12.0.8", "@expo/config-plugins": "~54.0.0", "@expo/devtools": "0.1.6", "@expo/fingerprint": "0.15.0", "@expo/metro": "~0.1.1", - "@expo/metro-config": "54.0.0", + "@expo/metro-config": "54.0.1", "@expo/vector-icons": "^15.0.2", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.0", - "expo-asset": "~12.0.7", - "expo-constants": "~18.0.7", + "expo-asset": "~12.0.8", + "expo-constants": "~18.0.8", "expo-file-system": "~19.0.11", "expo-font": "~14.0.7", "expo-keep-awake": "~15.0.6", - "expo-modules-autolinking": "3.0.9", + "expo-modules-autolinking": "3.0.10", "expo-modules-core": "3.0.15", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", @@ -9676,14 +9675,14 @@ } }, "node_modules/expo-asset": { - "version": "12.0.7", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.7.tgz", - "integrity": "sha512-Tf4cn/v2IitwLU44zG9h9bjfkdbVwjacKUISYWWZ5YvHPfRNNKPHUUwZcBEMa+6VNxsv1C9JkCJ9NDJe9R1x8Q==", + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.8.tgz", + "integrity": "sha512-jj2U8zw9+7orST2rlQGULYiqPoECOuUyffs2NguGrq84bYbkM041T7TOMXH2raPVJnM9lEAP54ezI6XL+GVYqw==", "license": "MIT", "peer": true, "dependencies": { - "@expo/image-utils": "^0.8.6", - "expo-constants": "~18.0.7" + "@expo/image-utils": "^0.8.7", + "expo-constants": "~18.0.8" }, "peerDependencies": { "expo": "*", @@ -9692,14 +9691,14 @@ } }, "node_modules/expo-constants": { - "version": "18.0.7", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.7.tgz", - "integrity": "sha512-B1KfvDL264/iNZKOoYnAYiDx4q6unvoRgF6pk3OghmUJ0adq4RGS9+2BCqU85h5Y9qfg+FI7TPOWX1Xtjq6cJg==", + "version": "18.0.8", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.8.tgz", + "integrity": "sha512-Tetphsx6RVImCTZeBAclRQMy0WOODY3y6qrUoc88YGUBVm8fAKkErCSWxLTCc6nFcJxdoOMYi62LgNIUFjZCLA==", "license": "MIT", "peer": true, "dependencies": { - "@expo/config": "~12.0.7", - "@expo/env": "~2.0.6" + "@expo/config": "~12.0.8", + "@expo/env": "~2.0.7" }, "peerDependencies": { "expo": "*", @@ -9757,9 +9756,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.9.tgz", - "integrity": "sha512-WyFGBcxWXo9lYZ2h1iBvE2GFPdgpjAfo45OKJLOIQhwckbeWv9849BcIVwGelEGEjoQ4jaugwo6UyaYIoCGHrw==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.10.tgz", + "integrity": "sha512-6pwaz9H7aK/iYraHbX7zjg8QFTUuMfGEs8Vyc6bAoBd8Rovtb91WX955Kq5sazwNrQjs3WePwQ23LEAmls3u5g==", "license": "MIT", "peer": true, "dependencies": { diff --git a/package.json b/package.json index ee20b54..66ee4cc 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "@ai-sdk/react": "^2.0.30", "@ai-sdk/ui-utils": "^1.2.11", "@atxp/client": "^0.2.22", - "@atxp/common": "^0.2.22", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/src/app.tsx b/src/app.tsx index ca75615..40dd40c 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -17,20 +17,18 @@ import { ToolInvocationCard } from "@/components/tool-invocation-card/ToolInvoca // Icon imports import { - Bug, - Moon, - Robot, - Sun, - Trash, - PaperPlaneTilt, - Stop + BugIcon, + MoonIcon, + RobotIcon, + SunIcon, + TrashIcon, + PaperPlaneTiltIcon, + StopIcon } from "@phosphor-icons/react"; // List of tools that require human confirmation // NOTE: this should match the tools that don't have execute functions in tools.ts -const toolsRequiringConfirmation: (keyof typeof tools)[] = [ - "getWeatherInformation" -]; +const toolsRequiringConfirmation: (keyof typeof tools)[] = []; export default function Chat() { const [theme, setTheme] = useState<"dark" | "light">(() => { @@ -163,7 +161,7 @@ export default function Chat() {
- + - {theme === "dark" ? : } + {theme === "dark" ? : }
@@ -199,7 +197,7 @@ export default function Chat() {
- +

Welcome to AI Chat

@@ -209,11 +207,7 @@ export default function Chat() {

  • - Weather information for any city -
  • -
  • - - Local time in different locations + To generate images
@@ -395,7 +389,7 @@ export default function Chat() { className="inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-primary/90 rounded-full p-1.5 h-fit border border-neutral-200 dark:border-neutral-800" aria-label="Stop generation" > - + ) : ( )} diff --git a/src/server.ts b/src/server.ts index 5f1d74e..43cfae0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { routeAgentRequest, type Schedule } from "agents"; -import { unstable_getSchedulePrompt } from "agents/schedule"; +import { getSchedulePrompt } from "agents/schedule"; import { AIChatAgent } from "agents/ai-chat-agent"; import { @@ -16,14 +16,16 @@ import { import { openai } from "@ai-sdk/openai"; import { processToolCalls, cleanupMessages } from "./utils"; import { tools, executions } from "./tools"; +import { cloudflareWorkersFetch } from "./tools/imageGeneration"; import { findATXPAccount, imageService, - filestoreService, - type ATXPPayment + type ATXPPayment, + type MCPToolResult, + type ImageGenerationTask } from "./utils/atxp"; import { atxpClient } from "@atxp/client"; -import { ConsoleLogger, LogLevel } from "@atxp/common"; +// Removed unused import // import { env } from "cloudflare:workers"; const model = openai("gpt-4o-2024-11-20"); @@ -33,21 +35,6 @@ const model = openai("gpt-4o-2024-11-20"); // baseURL: env.GATEWAY_BASE_URL, // }); -/** - * Image generation task interface for state management - */ -interface ImageGenerationTask { - id: string; - prompt: string; - status: "pending" | "processing" | "completed" | "failed"; - taskId?: string; - imageUrl?: string; - fileName?: string; - fileId?: string; - createdAt: Date; - updatedAt: Date; -} - /** * Chat Agent implementation that handles real-time AI chat interactions */ @@ -88,19 +75,39 @@ export class Chat extends AIChatAgent { I have access to the following image generation capabilities: - generateImage: Create images from text prompts using ATXP Image MCP server -- getImageGenerationStatus: Check the status of image generation tasks +- getImageGenerationStatus: Check the status of a specific image generation task (requires taskId) - listImageGenerationTasks: List all image generation tasks -When a user asks to generate or create an image, use the generateImage tool with their description as the prompt. +CRITICAL RULES FOR IMAGE GENERATION: +1. When a user asks to generate or create an image: + - Use ONLY the generateImage tool with their description as the prompt + - After starting, inform the user they'll be automatically notified when complete + - DO NOT call any other image tools after generateImage + +2. When a user asks to "check status" or "check image status" WITHOUT specifying a task ID: + - Use listImageGenerationTasks to show all tasks + - DO NOT call generateImage or getImageGenerationStatus + - DO NOT generate new images + +3. When a user asks to check status WITH a specific task ID: + - Use getImageGenerationStatus with that taskId + - DO NOT call generateImage + +4. NEVER call getImageGenerationStatus without a specific taskId +5. NEVER call generateImage when user asks to check status + +The system has automatic background polling that will: +- Check image generation status every 10 seconds automatically +- Send completion notifications with inline image display when ready +- Handle any errors or failures automatically ATXP Connection Strings: - If no global connection string is set, users can provide it in the URL format: https://accounts.atxp.ai?connection_token=ABC123DEF456 -- Connection strings can be obtained from https://console.atxp.ai -- Both URL format (https://accounts.atxp.ai?connection_token=...) and legacy JSON format are supported +- Connection strings can be obtained from https://accounts.atxp.ai I can also schedule tasks for later execution. -${unstable_getSchedulePrompt({ date: new Date() })} +${getSchedulePrompt({ date: new Date() })} If the user asks to schedule a task, use the schedule tool to schedule the task. `, @@ -150,46 +157,108 @@ If the user asks to schedule a task, use the schedule tool to schedule the task. taskId: string; atxpConnectionString: string; }) { - const { requestId, taskId, atxpConnectionString } = params; + const { taskId, atxpConnectionString } = params; try { - // Get the current task data - const taskData = (await this.state.storage.get( - `imageTask:${requestId}` - )) as ImageGenerationTask | null; - - if (!taskData || taskData.status !== "processing") { - console.log( - `Task ${requestId} is not in processing state, stopping polling` + let taskData: ImageGenerationTask | null = null; + + try { + // @ts-expect-error - Durable Objects storage returns unknown type + const storageResult = await this.state.storage.get( + `imageTask:${taskId}` ); + taskData = storageResult as unknown as ImageGenerationTask | null; + } catch (_storageError) { + // Storage failed - continue without storage data + taskData = null; + } + + if (!taskData) { + // Create a minimal task for polling when storage fails + taskData = { + id: taskId, + prompt: "Generated image", + status: "running" as const, + taskId, + createdAt: new Date(), + updatedAt: new Date() + }; + } + + if (taskData.status !== "running") { + // Task is not running anymore, stop polling return; } - // Get ATXP account - const account = findATXPAccount(atxpConnectionString); + // Get ATXP account with custom fetch function + const account = findATXPAccount( + atxpConnectionString, + cloudflareWorkersFetch + ); // Create ATXP Image client - const imageClient = await atxpClient({ - mcpServer: imageService.mcpServer, - account: account, - logger: new ConsoleLogger({ level: LogLevel.DEBUG }), - onPayment: async ({ payment }: { payment: ATXPPayment }) => { - console.log("Payment made to image service during polling:", payment); - await (this.broadcast as any)({ - type: "payment-update", - taskId: requestId, - payment: { - accountId: payment.accountId, - resourceUrl: payment.resourceUrl, - resourceName: payment.resourceName, - network: payment.network, - currency: payment.currency, - amount: payment.amount.toString(), - iss: payment.iss + let imageClient: Awaited>; + try { + imageClient = await atxpClient({ + mcpServer: imageService.mcpServer, + account: account, + fetchFn: cloudflareWorkersFetch, + oAuthChannelFetch: cloudflareWorkersFetch, + onPayment: async ({ payment }: { payment: ATXPPayment }) => { + // Send broadcast for real-time updates + await this.broadcast( + JSON.stringify({ + type: "payment-update", + taskId: taskId, + payment: { + accountId: payment.accountId, + resourceUrl: payment.resourceUrl, + resourceName: payment.resourceName, + network: payment.network, + currency: payment.currency, + amount: payment.amount.toString(), + iss: payment.iss + } + }) + ); + + // Add payment notification message to chat + try { + await this.saveMessages([ + ...this.messages, + { + id: generateId(), + role: "assistant", + parts: [ + { + type: "text", + text: `💳 **Payment Processed During Image Generation** + +A payment has been processed for your ongoing image generation (Task ID: ${taskId}): +- **Amount:** ${payment.amount.toString()} ${payment.currency} +- **Network:** ${payment.network} +- **Service:** ${payment.resourceName} + +Your image generation continues processing...` + } + ], + metadata: { + createdAt: new Date() + } + } + ]); + } catch (messageError) { + console.error( + `Failed to add payment message for ${taskId}:`, + messageError + ); } - }); - } - }); + } + }); + } catch (error) { + console.error("[POLLING] Failed to create image client:", error); + return; + } // Check the status of the image generation const statusResult = await imageClient.callTool({ @@ -197,124 +266,104 @@ If the user asks to schedule a task, use the schedule tool to schedule the task. arguments: { taskId } }); - const { status, url } = imageService.getAsyncStatusResult(statusResult); - console.log(`Task ${taskId} status:`, status); + const { status, url } = imageService.getAsyncStatusResult( + statusResult as MCPToolResult + ); if (status === "completed" && url) { - console.log(`Task ${taskId} completed successfully. URL:`, url); - // Update task with completed status taskData.status = "completed"; taskData.imageUrl = url; taskData.updatedAt = new Date(); - // Try to store in filestore + // Try to save updated task data (but don't fail if storage doesn't work) try { - // Send progress update for file storage - await (this.broadcast as any)({ - type: "image-generation-storing", - taskId: requestId, - message: "Storing image in ATXP Filestore..." - }); - - // Create filestore client - const filestoreClient = await atxpClient({ - mcpServer: filestoreService.mcpServer, - account: account, - onPayment: async ({ payment }: { payment: ATXPPayment }) => { - console.log("Payment made to filestore:", payment); - await (this.broadcast as any)({ - type: "payment-update", - taskId: requestId, - payment: { - accountId: payment.accountId, - resourceUrl: payment.resourceUrl, - resourceName: payment.resourceName, - network: payment.network, - currency: payment.currency, - amount: payment.amount.toString(), - iss: payment.iss - } - }); - } - }); - - const filestoreResult = await filestoreClient.callTool({ - name: filestoreService.toolName, - arguments: filestoreService.getArguments(url) - }); - - const fileResult = filestoreService.getResult(filestoreResult); - taskData.fileName = fileResult.filename; - taskData.imageUrl = fileResult.url; // Use filestore URL instead - taskData.fileId = fileResult.fileId || fileResult.filename; - - console.log("Filestore result:", fileResult); - } catch (filestoreError) { - console.error( - "Error with filestore, using direct image URL:", - filestoreError + // @ts-expect-error - taskData type assertion issue with Durable Objects storage + await this.state.storage.put( + `imageTask:${taskId}`, + taskData as ImageGenerationTask ); - - // Send filestore warning but continue with direct URL - await (this.broadcast as any)({ - type: "image-generation-warning", - taskId: requestId, - message: "Image ready! Filestore unavailable, using direct URL." - }); + } catch (_storageError) { + // Storage update failed, but continue anyway } - // Save updated task data - await this.state.storage.put(`imageTask:${requestId}`, taskData as any); - // Send final completion update - await (this.broadcast as any)({ - type: "image-generation-completed", - taskId: requestId, - imageUrl: taskData.imageUrl, - fileName: taskData.fileName, - message: `✅ Image generation completed! Your image "${taskData.prompt}" is ready.` - }); + try { + await this.broadcast( + JSON.stringify({ + type: "image-generation-completed", + taskId: taskId, + imageUrl: taskData.imageUrl, + fileName: taskData.fileName, + message: `✅ Image generation completed! Your image "${taskData.prompt}" is ready.` + }) + ); + } catch (_broadcastError) { + // Broadcast failed, but continue anyway + } // Add completion message to chat - await this.saveMessages([ - ...this.messages, - { - id: generateId(), - role: "assistant", - parts: [ - { - type: "text", - text: `🎨 **Image Generation Complete!** + try { + await this.saveMessages([ + ...this.messages, + { + id: generateId(), + role: "assistant", + parts: [ + { + type: "text", + text: `🎨 **Image Generation Complete!** Your image for "${taskData.prompt}" has been generated successfully! +![Image](${taskData.imageUrl}) **Image URL:** ${taskData.imageUrl} ${taskData.fileName ? `**File Name:** ${taskData.fileName}` : ""} The image generation process is now complete.` + } + ], + metadata: { + createdAt: new Date() } - ], - metadata: { - createdAt: new Date() } - } - ]); - } else if (status === "failed") { - console.error(`Task ${taskId} failed`); + ]); + } catch (messageError) { + console.error( + `Failed to add completion message for ${taskId}:`, + messageError + ); + } + // Stop polling this completed task + return; + } else if (status === "failed") { // Update task status to failed taskData.status = "failed"; taskData.updatedAt = new Date(); - await this.state.storage.put(`imageTask:${requestId}`, taskData as any); + + try { + // @ts-expect-error - taskData type assertion issue with Durable Objects storage + await this.state.storage.put( + `imageTask:${taskId}`, + taskData as ImageGenerationTask + ); + } catch (_storageError) { + // Storage update failed, but continue anyway + } // Send failure update - await (this.broadcast as any)({ - type: "image-generation-failed", - taskId: requestId, - message: `❌ Image generation failed for "${taskData.prompt}"` - }); - } else if (status === "processing") { + await this.broadcast( + JSON.stringify({ + type: "image-generation-failed", + taskId, + message: `❌ Image generation failed for "${taskData.prompt}"` + }) + ); + + // Stop polling this failed task + return; + } else if (status === "running") { // Still processing, schedule another check in 10 seconds this.schedule( new Date(Date.now() + 10000), // Check again in 10 seconds @@ -323,11 +372,13 @@ The image generation process is now complete.` ); // Send periodic progress update - await (this.broadcast as any)({ - type: "image-generation-progress", - taskId: requestId, - message: `🔄 Still generating image for "${taskData.prompt}"...` - }); + await this.broadcast( + JSON.stringify({ + type: "image-generation-progress", + taskId, + message: `🔄 Still generating image for "${taskData.prompt}"...` + }) + ); } } catch (error) { console.error(`Error polling for task ${taskId}:`, error); @@ -340,11 +391,13 @@ The image generation process is now complete.` ); // Send error update - await this.broadcast({ - type: "image-generation-error", - taskId: requestId, - message: `⚠️ Error checking image generation status. Retrying...` - }); + await this.broadcast( + JSON.stringify({ + type: "image-generation-error", + taskId, + message: `⚠️ Error checking image generation status. Retrying...` + }) + ); } } } @@ -357,12 +410,14 @@ export default { const url = new URL(request.url); if (url.pathname === "/check-open-ai-key") { - const hasOpenAIKey = !!process.env.OPENAI_API_KEY; + const hasOpenAIKey = !!env.OPENAI_API_KEY; return Response.json({ success: hasOpenAIKey }); } - if (!process.env.OPENAI_API_KEY) { + if (!env.OPENAI_API_KEY) { + // Note: Using console.error here is acceptable as this runs in the worker entry point + // where console methods are available and this is a critical startup error console.error( "OPENAI_API_KEY is not set, don't forget to set it locally in .dev.vars, and use `wrangler secret bulk .dev.vars` to upload it to production" ); diff --git a/src/tools.ts b/src/tools.ts index 175d598..e573b6f 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -7,40 +7,16 @@ import { z } from "zod/v3"; import type { Chat } from "./server"; import { getCurrentAgent } from "agents"; -import { unstable_scheduleSchema } from "agents/schedule"; +import { scheduleSchema } from "agents/schedule"; import { generateImage, getImageGenerationStatus, listImageGenerationTasks } from "./tools/imageGeneration"; -/** - * Weather information tool that requires human confirmation - * When invoked, this will present a confirmation dialog to the user - */ -const getWeatherInformation = tool({ - description: "show the weather in a given city to the user", - inputSchema: z.object({ city: z.string() }) - // Omitting execute function makes this tool require human confirmation -}); - -/** - * Local time tool that executes automatically - * Since it includes an execute function, it will run without user confirmation - * This is suitable for low-risk operations that don't need oversight - */ -const getLocalTime = tool({ - description: "get the local time for a specified location", - inputSchema: z.object({ location: z.string() }), - execute: async ({ location }) => { - console.log(`Getting local time for ${location}`); - return "10am"; - } -}); - const scheduleTask = tool({ description: "A tool to schedule a task to be executed at a later time", - inputSchema: unstable_scheduleSchema, + inputSchema: scheduleSchema, execute: async ({ when, description }) => { // we can now read the agent context from the ALS store const { agent } = getCurrentAgent(); @@ -118,8 +94,6 @@ const cancelScheduledTask = tool({ * These will be provided to the AI model to describe available capabilities */ export const tools = { - getWeatherInformation, - getLocalTime, scheduleTask, getScheduledTasks, cancelScheduledTask, @@ -133,9 +107,4 @@ export const tools = { * This object contains the actual logic for tools that need human approval * Each function here corresponds to a tool above that doesn't have an execute function */ -export const executions = { - getWeatherInformation: async ({ city }: { city: string }) => { - console.log(`Getting weather information for ${city}`); - return `The weather in ${city} is sunny`; - } -}; +export const executions = {}; diff --git a/src/tools/imageGeneration.ts b/src/tools/imageGeneration.ts index ba681f7..5ae03c0 100644 --- a/src/tools/imageGeneration.ts +++ b/src/tools/imageGeneration.ts @@ -1,34 +1,27 @@ -import { tool } from "ai"; +import { tool, generateId } from "ai"; import { z } from "zod/v3"; -import { getCurrentAgent } from "agents"; -import type { Chat } from "../server"; import { getATXPConnectionString, findATXPAccount, imageService, + type MCPToolResult, + type ImageGenerationTask, type ATXPPayment } from "../utils/atxp"; import { atxpClient } from "@atxp/client"; -import { ConsoleLogger, LogLevel } from "@atxp/common"; +import { getCurrentAgent } from "agents"; +import type { Chat } from "../server"; -/** - * Image generation task interface for state management - */ -interface ImageGenerationTask { - id: string; - prompt: string; - status: "pending" | "processing" | "completed" | "failed"; - taskId?: string; - imageUrl?: string; - fileName?: string; - fileId?: string; - createdAt: Date; - updatedAt: Date; -} +export const cloudflareWorkersFetch = async ( + input: RequestInfo | URL, + init?: RequestInit +) => { + return await globalThis.fetch(input, init); +}; /** * Generate an image from a text prompt using ATXP Image MCP server - * This tool executes automatically and handles the async image generation process + * This tool returns task information for the agent to handle */ export const generateImage = tool({ description: @@ -41,120 +34,140 @@ export const generateImage = tool({ .string() .optional() .describe( - "ATXP connection string - either URL format (https://accounts.atxp.ai?connection_token=...) or JSON format (if not set in environment)" + "ATXP connection string - either URL format (https://accounts.atxp.ai?connection_token=...). This is optional and ONLY if the user wants to override the default setting" ) }), execute: async ({ prompt, connectionString }) => { - const { agent } = getCurrentAgent(); + try { + if (!prompt || prompt.trim() === "") { + return "Error: Image prompt cannot be empty"; + } - if (!prompt || prompt.trim() === "") { - return "Error: Image prompt cannot be empty"; - } + // Get agent to access environment variables + const { agent } = getCurrentAgent(); - const requestId = Date.now().toString(); - const taskData: ImageGenerationTask = { - id: requestId, - prompt: prompt.trim(), - status: "pending", - createdAt: new Date(), - updatedAt: new Date() - }; + const atxpConnectionString = getATXPConnectionString( + connectionString + // Note: agent.env is protected, so we fall back to process.env in getATXPConnectionString + ); - try { - // Get ATXP connection string and account - const atxpConnectionString = getATXPConnectionString(connectionString); - const account = findATXPAccount(atxpConnectionString); - - // Store initial task state - await agent!.state.storage.put(`imageTask:${requestId}`, taskData as any); - - // Send progress update - await (agent!.broadcast as any)({ - type: "image-generation-started", - taskId: requestId, - prompt: prompt, - message: "Starting image generation..." - }); + const account = findATXPAccount( + atxpConnectionString, + cloudflareWorkersFetch + ); - // Create ATXP Image client const imageClient = await atxpClient({ mcpServer: imageService.mcpServer, account: account, - logger: new ConsoleLogger({ level: LogLevel.DEBUG }), + fetchFn: cloudflareWorkersFetch, // Use our custom fetch function + oAuthChannelFetch: cloudflareWorkersFetch, // Explicitly set OAuth channel fetch onPayment: async ({ payment }: { payment: ATXPPayment }) => { - console.log("Payment made to image service:", payment); - await (agent!.broadcast as any)({ - type: "payment-update", - taskId: requestId, - payment: { - accountId: payment.accountId, - resourceUrl: payment.resourceUrl, - resourceName: payment.resourceName, - network: payment.network, - currency: payment.currency, - amount: payment.amount.toString(), - iss: payment.iss + // Add payment notification message to chat + if (agent) { + try { + await agent.saveMessages([ + ...agent.messages, + { + id: generateId(), + role: "assistant", + parts: [ + { + type: "text", + text: `💳 **Payment Processed for Image Generation** + +A payment has been processed for your image generation request: +- **Amount:** ${payment.amount.toString()} ${payment.currency} +- **Network:** ${payment.network} +- **Service:** ${payment.resourceName} + +Your image generation is now in progress!` + } + ], + metadata: { + createdAt: new Date() + } + } + ]); + } catch (_messageError) { + // Continue if message saving fails } - }); + } } }); - // Start async image generation const asyncResult = await imageClient.callTool({ name: imageService.createImageAsyncToolName, arguments: imageService.getArguments(prompt) }); - const { taskId } = imageService.getAsyncCreateResult(asyncResult); - console.log("Async image generation started with task ID:", taskId); - - // Update task with processing status - taskData.taskId = taskId; - taskData.status = "processing"; - taskData.updatedAt = new Date(); - await agent!.state.storage.put(`imageTask:${requestId}`, taskData as any); - - // Send progress update - await (agent!.broadcast as any)({ - type: "image-generation-processing", - taskId: requestId, - atxpTaskId: taskId, - message: `Image generation started (Task ID: ${taskId})` - }); + const taskId = imageService.getAsyncCreateResult( + asyncResult as MCPToolResult + ).taskId; + + // Store task in agent storage and start polling + if (agent) { + try { + const task: ImageGenerationTask = { + id: generateId(), + prompt: prompt.trim(), + status: "running", + taskId, + createdAt: new Date(), + updatedAt: new Date() + }; + + // Store task data + try { + // @ts-expect-error - Durable Objects storage type assertion + await agent.state.storage.put(`imageTask:${taskId}`, task); + } catch (_storageError) { + console.log( + `[IMAGE-GEN] Storage failed, continuing without storage` + ); + // Continue without storage - polling can still work + } + + // Start polling for completion + const scheduledTime = new Date(Date.now() + 10000); // Check in 10 seconds + const scheduleParams = { + requestId: generateId(), + taskId, + atxpConnectionString + }; + + agent.schedule( + scheduledTime, + "pollImageGenerationTask", + scheduleParams + ); + } catch (scheduleError) { + console.error( + `[IMAGE-GEN] Error in storage/scheduling:`, + scheduleError + ); + // Continue without scheduling - the image was created successfully + } + } - // Schedule background polling for this task - agent!.schedule( - new Date(Date.now() + 5000), // Start polling in 5 seconds - "pollImageGenerationTask", - { requestId, taskId, atxpConnectionString } - ); + // Return task information for the agent to handle + const result = { + type: "image_generation_started", + taskId, + prompt: prompt.trim(), + atxpConnectionString, + message: `🎨 Image generation started successfully! - return `🎨 Image generation started successfully! - -**Task ID:** ${requestId} **ATXP Task ID:** ${taskId} **Prompt:** "${prompt}" -**Status:** Processing +**Status:** running -I'll keep you updated on the progress. This usually takes 1-2 minutes to complete.`; - } catch (error) { - console.error(`Error starting image generation:`, error); - - // Update task status to failed - taskData.status = "failed"; - taskData.updatedAt = new Date(); - await agent!.state.storage.put(`imageTask:${requestId}`, taskData as any); - - // Send error update - await (agent!.broadcast as any)({ - type: "image-generation-error", - taskId: requestId, - error: error instanceof Error ? error.message : "Unknown error occurred" - }); +The system will automatically check the progress and notify you when it's complete. This usually takes 1-2 minutes.` + }; - const errorMessage = - error instanceof Error ? error.message : "Unknown error occurred"; - return `❌ Failed to start image generation: ${errorMessage}`; + return result; + } catch (error) { + console.error(`[IMAGE-GEN] Error in generateImage:`, error); + return `Error generating image: ${error instanceof Error ? error.message : "Unknown error"}`; } } }); @@ -165,51 +178,97 @@ I'll keep you updated on the progress. This usually takes 1-2 minutes to complet export const getImageGenerationStatus = tool({ description: "Get the status of a previously started image generation task", inputSchema: z.object({ - taskId: z.string().describe("The task ID returned from generateImage") + taskId: z.string().describe("The ATXP task ID returned from generateImage"), + connectionString: z + .string() + .optional() + .describe("ATXP connection string (if not set in environment)") }), - execute: async ({ taskId }) => { - const { agent } = getCurrentAgent(); - + execute: async ({ taskId, connectionString }) => { try { - const taskData = (await agent!.state.storage.get( - `imageTask:${taskId}` - )) as ImageGenerationTask | null; - - if (!taskData) { - return `❌ No image generation task found with ID: ${taskId}`; - } + const atxpConnectionString = getATXPConnectionString( + connectionString + // Note: agent.env is protected, so we fall back to process.env in getATXPConnectionString + ); + const account = findATXPAccount( + atxpConnectionString, + cloudflareWorkersFetch + ); - const statusEmoji: Record = { - pending: "⏳", - processing: "🔄", - completed: "✅", - failed: "❌" - }; - const emoji = statusEmoji[taskData.status] || "❓"; + const imageClient = await atxpClient({ + mcpServer: imageService.mcpServer, + account: account, + fetchFn: cloudflareWorkersFetch, + oAuthChannelFetch: cloudflareWorkersFetch, + onPayment: async ({ payment }: { payment: ATXPPayment }) => { + // Add payment notification message to chat for status checks + const { agent } = getCurrentAgent(); + if (agent) { + try { + await agent.saveMessages([ + ...agent.messages, + { + id: generateId(), + role: "assistant", + parts: [ + { + type: "text", + text: `💳 **Payment Processed for Image Status Check** + +A payment has been processed while checking image status (Task ID: ${taskId}): +- **Amount:** ${payment.amount.toString()} ${payment.currency} +- **Network:** ${payment.network} +- **Service:** ${payment.resourceName} + +Status check complete.` + } + ], + metadata: { + createdAt: new Date() + } + } + ]); + } catch (_messageError) { + // Continue if message saving fails + } + } + } + }); - let response = `${emoji} **Image Generation Status** + // Call the MCP server to get image status + const statusResult = await imageClient.callTool({ + name: imageService.getImageAsyncToolName, + arguments: { taskId } + }); -**Task ID:** ${taskData.id} -**Prompt:** "${taskData.prompt}" -**Status:** ${taskData.status} -**Created:** ${taskData.createdAt.toISOString()} -**Updated:** ${taskData.updatedAt.toISOString()}`; + // Parse the status result + const { status, url } = imageService.getAsyncStatusResult( + statusResult as MCPToolResult + ); - if (taskData.taskId) { - response += `\n**ATXP Task ID:** ${taskData.taskId}`; - } + return { + type: "image_generation_status", + taskId, + status, + url, + message: + status === "completed" && url + ? `🎉 Image generation completed! - if (taskData.status === "completed" && taskData.imageUrl) { - response += `\n**Image URL:** ${taskData.imageUrl}`; - if (taskData.fileName) { - response += `\n**File Name:** ${taskData.fileName}`; - } - } +![Generated Image](${url}) - return response; +**Download URL:** ${url}` + : `📊 Image generation status: ${status}` + }; } catch (error) { - console.error(`Error getting task status:`, error); - return `❌ Error retrieving task status: ${error instanceof Error ? error.message : "Unknown error"}`; + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + type: "image_generation_error", + taskId, + error: errorMessage, + message: `❌ Failed to get image status: ${errorMessage}` + }; } } }); @@ -222,53 +281,59 @@ export const listImageGenerationTasks = tool({ "List all image generation tasks (completed, in progress, and failed)", inputSchema: z.object({}), execute: async () => { - const { agent } = getCurrentAgent(); - try { - const allKeys = (await agent!.state.storage.list({ - prefix: "imageTask:" - })) as Map; + const { agent } = getCurrentAgent(); + if (!agent) { + return { + type: "list_image_tasks_error", + message: "Unable to access agent storage" + }; + } - if (allKeys.size === 0) { - return "📋 No image generation tasks found."; + // Get all storage keys that start with "imageTask:" + // @ts-expect-error - Durable Objects storage list method + const allKeys = await agent.state.storage.list({ prefix: "imageTask:" }); + const tasks = []; + + for (const [, taskData] of allKeys.entries()) { + const task = taskData as ImageGenerationTask; + tasks.push({ + taskId: task.taskId, + prompt: task.prompt, + status: task.status, + imageUrl: task.imageUrl, + createdAt: task.createdAt, + updatedAt: task.updatedAt + }); } - const tasks: ImageGenerationTask[] = []; - for (const [_, taskData] of allKeys) { - tasks.push(taskData as ImageGenerationTask); + if (tasks.length === 0) { + return { + type: "list_image_tasks", + tasks: [], + message: "No image generation tasks found." + }; } - // Sort by creation date (newest first) + // Sort by creation date, most recent first tasks.sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); - let response = `📋 **Image Generation Tasks** (${tasks.length} total)\n\n`; - - tasks.forEach((task, index) => { - const statusEmoji = { - pending: "⏳", - processing: "🔄", - completed: "✅", - failed: "❌" - }[task.status]; - - response += `**${index + 1}.** ${statusEmoji} ${task.id}\n`; - response += ` Prompt: "${task.prompt}"\n`; - response += ` Status: ${task.status}\n`; - response += ` Created: ${new Date(task.createdAt).toLocaleString()}\n`; - - if (task.status === "completed" && task.imageUrl) { - response += ` Image: ${task.imageUrl}\n`; - } - response += "\n"; - }); - - return response; + return { + type: "list_image_tasks", + tasks, + message: `Found ${tasks.length} image generation task${tasks.length === 1 ? "" : "s"}:` + }; } catch (error) { - console.error(`Error listing tasks:`, error); - return `❌ Error listing tasks: ${error instanceof Error ? error.message : "Unknown error"}`; + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + type: "list_image_tasks_error", + error: errorMessage, + message: `Failed to list image generation tasks: ${errorMessage}` + }; } } }); diff --git a/src/utils/atxp.ts b/src/utils/atxp.ts index dc78c03..b67c136 100644 --- a/src/utils/atxp.ts +++ b/src/utils/atxp.ts @@ -1,4 +1,4 @@ -import type { ATXPAccount } from "@atxp/client"; +import { ATXPAccount } from "@atxp/client"; /** * ATXP utility functions for handling connection strings and account validation @@ -8,14 +8,19 @@ import type { ATXPAccount } from "@atxp/client"; * Get ATXP connection string from environment variable or provided string * Priority: provided connectionString > ATXP_CONNECTION_STRING env var */ -export function getATXPConnectionString(connectionString?: string): string { +export function getATXPConnectionString( + connectionString?: string, + env?: Env +): string { // First try the provided connection string if (connectionString && connectionString.trim() !== "") { return connectionString.trim(); } - // Fall back to environment variable - const envConnectionString = process.env.ATXP_CONNECTION_STRING; + // Fall back to Cloudflare Workers environment variable + const envConnectionString = + env?.ATXP_CONNECTION_STRING || process.env.ATXP_CONNECTION_STRING; + if (envConnectionString && envConnectionString.trim() !== "") { return envConnectionString.trim(); } @@ -30,7 +35,10 @@ export function getATXPConnectionString(connectionString?: string): string { * Supports both URL format (https://accounts.atxp.ai?connection_token=ABC123) * and legacy JSON format for backwards compatibility */ -export function findATXPAccount(connectionString: string): ATXPAccount { +export function findATXPAccount( + connectionString: string, + fetchFn: typeof fetch +): ATXPAccount { if (!connectionString || connectionString.trim() === "") { throw new Error("ATXP connection string cannot be empty"); } @@ -47,16 +55,8 @@ export function findATXPAccount(connectionString: string): ATXPAccount { ); } - return { - connectionToken: connectionToken, - // Required fields for ATXPAccount interface - accountId: "", // Empty for URL-based connections - privateKey: "", // Empty for URL-based connections - // Default values for URL-based connections - network: "mainnet", - currency: "ETH", - paymentMakers: [] - } as ATXPAccount; + // Create a proper ATXPAccount instance with the connection string + return new ATXPAccount(connectionString, { fetchFn }); } // Legacy JSON format support @@ -75,16 +75,8 @@ export function findATXPAccount(connectionString: string): ATXPAccount { ); } - return { - accountId: parsed.accountId, - privateKey: parsed.privateKey, - connectionToken: parsed.connectionToken, - // Optional fields - network: parsed.network || "mainnet", - currency: parsed.currency || "ETH", - // Add required field with empty array as default - paymentMakers: parsed.paymentMakers || [] - } as ATXPAccount; + // Create a proper ATXPAccount instance with the connection string + return new ATXPAccount(connectionString, { fetchFn }); } catch (error) { if (error instanceof SyntaxError) { throw new Error( @@ -96,32 +88,45 @@ export function findATXPAccount(connectionString: string): ATXPAccount { } /** - * Validate ATXP connection string format and content + * MCP Tool Result interface - flexible to handle different content types */ -export function validateATXPConnectionString(connectionString?: string): { - isValid: boolean; - error?: string; -} { - try { - const cs = getATXPConnectionString(connectionString); - findATXPAccount(cs); - return { isValid: true }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown validation error"; - return { isValid: false, error: errorMessage }; - } +export interface MCPToolResult { + // biome-ignore lint/suspicious/noExplicitAny: MCP content can be flexible types + content: Array<{ text?: string; type?: string; [key: string]: any }>; +} +type ImageGenerationStatus = "pending" | "running" | "completed" | "failed"; +/** + * Image generation task interface for state management + */ +export interface ImageGenerationTask { + id: string; + prompt: string; + status: ImageGenerationStatus; + taskId?: string; + imageUrl?: string; + fileName?: string; + fileId?: string; + createdAt: Date; + updatedAt: Date; } /** - * MCP Tool Result interface + * Type guard for ImageGenerationTask */ -interface MCPToolResult { - content: Array<{ text: string }>; +export function isImageGenerationTask( + obj: unknown +): obj is ImageGenerationTask { + return ( + obj != null && + typeof obj === "object" && + "id" in obj && + "prompt" in obj && + "status" in obj + ); } /** - * ATXP Payment information + * ATXP Payment information from @atxp/client */ export interface ATXPPayment { accountId: string; @@ -129,7 +134,8 @@ export interface ATXPPayment { resourceName: string; network: string; currency: string; - amount: string | number; + // biome-ignore lint/suspicious/noExplicitAny: BigNumber type from @atxp/client + amount: any; // BigNumber from @atxp/client iss: string; } @@ -142,13 +148,15 @@ export const imageService = { getImageAsyncToolName: "image_get_image_async", description: "ATXP Image MCP server", getArguments: (prompt: string) => ({ prompt }), - getAsyncCreateResult: (result: MCPToolResult) => { - const jsonString = result.content[0].text; + getAsyncCreateResult: (result: MCPToolResult): { taskId: string } => { + const jsonString = result.content[0].text || ""; const parsed = JSON.parse(jsonString); return { taskId: parsed.taskId }; }, - getAsyncStatusResult: (result: MCPToolResult) => { - const jsonString = result.content[0].text; + getAsyncStatusResult: ( + result: MCPToolResult + ): { status: ImageGenerationStatus; url?: string } => { + const jsonString = result.content[0].text || ""; const parsed = JSON.parse(jsonString); return { status: parsed.status, url: parsed.url }; } @@ -162,9 +170,11 @@ export const filestoreService = { toolName: "filestore_write", description: "ATXP Filestore MCP server", getArguments: (sourceUrl: string) => ({ sourceUrl, makePublic: true }), - getResult: (result: MCPToolResult) => { + getResult: ( + result: MCPToolResult + ): { filename: string; url: string; fileId?: string } => { // Parse the JSON string from the result - const jsonString = result.content[0].text; + const jsonString = result.content[0].text || ""; return JSON.parse(jsonString); } };