├── .cursor └── rules │ └── cloudflare.mdc ├── .dev.vars.example ├── .github └── workflows │ └── sanity-check.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── biome.json ├── components.json ├── index.html ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── src ├── app.tsx ├── client.tsx ├── components │ ├── avatar │ │ └── Avatar.tsx │ ├── button │ │ ├── Button.tsx │ │ └── RefreshButton.tsx │ ├── card │ │ └── Card.tsx │ ├── dropdown │ │ └── DropdownMenu.tsx │ ├── input │ │ └── Input.tsx │ ├── label │ │ └── Label.tsx │ ├── loader │ │ └── Loader.tsx │ ├── memoized-markdown.tsx │ ├── menu-bar │ │ └── MenuBar.tsx │ ├── modal │ │ └── Modal.tsx │ ├── orbit-site │ │ ├── Block.tsx │ │ ├── Inset.tsx │ │ ├── Section.tsx │ │ └── ThemeSelector.tsx │ ├── select │ │ └── Select.tsx │ ├── slot │ │ └── Slot.tsx │ ├── toggle │ │ └── Toggle.tsx │ ├── tool-invocation-card │ │ └── ToolInvocationCard.tsx │ └── tooltip │ │ └── Tooltip.tsx ├── hooks │ ├── useClickOutside.tsx │ ├── useMenuNavigation.tsx │ └── useTheme.ts ├── lib │ └── utils.ts ├── providers │ ├── ModalProvider.tsx │ ├── TooltipProvider.tsx │ └── index.tsx ├── server.ts ├── shared.ts ├── styles.css ├── tools.ts └── utils.ts ├── tests ├── index.test.ts └── tsconfig.json ├── tsconfig.json ├── vite.config.ts ├── vitest.config.ts ├── worker-configuration.d.ts └── wrangler.jsonc /.cursor/rules/cloudflare.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Building Cloudflare Workers and Agents 3 | globs: 4 | alwaysApply: false 5 | --- 6 | 7 | You are an advanced assistant specialized in generating Cloudflare Workers code. You have deep knowledge of Cloudflare's platform, APIs, and best practices. 8 | 9 | 10 | 11 | 12 | - Respond in a friendly and concise manner 13 | - Focus exclusively on Cloudflare Workers solutions 14 | - Provide complete, self-contained solutions 15 | - Default to current best practices 16 | - Ask clarifying questions when requirements are ambiguous 17 | 18 | 19 | 20 | 21 | 22 | - Generate code in TypeScript by default unless JavaScript is specifically requested 23 | - Use ES modules format exclusively (never use Service Worker format) 24 | - You SHALL keep all code in a single file unless otherwise specified 25 | - Minimize external dependencies, unless there is an official SDK or library for the service you are integrating with, then use it to simplify the implementation. 26 | - Do not use libraries that have FFI/native/C bindings. 27 | - Follow Cloudflare Workers security best practices 28 | - Never bake in secrets into the code 29 | - Include proper error handling and logging 30 | - Add appropriate TypeScript types and interfaces 31 | - Include comments explaining complex logic 32 | 33 | 34 | 35 | 36 | 37 | - Use markdown code blocks to separate code from explanations 38 | - Provide separate blocks for: 39 | 1. Main worker code (index.ts/index.js) 40 | 2. Configuration (wrangler.jsonc) 41 | 3. Type definitions (if applicable) 42 | 4. Example usage/tests 43 | - Always output complete files, never partial updates or diffs 44 | - Format code consistently using standard TypeScript/JavaScript conventions 45 | 46 | 47 | 48 | 49 | 50 | - When data storage is needed, integrate with appropriate Cloudflare services: 51 | - Workers KV for key-value storage, including configuration data, user profiles, and A/B testing 52 | - Durable Objects for strongly consistent state management, storage, and multiplayer co-ordination use-cases 53 | - D1 for relational data and for its SQL dialect 54 | - R2 for object storage, including storing structured data, AI assets, image assets and for user-facing uploads 55 | - Hyperdrive to connect to existing (PostgreSQL) databases that a developer may already have 56 | - Queues for asynchronous processing and background tasks 57 | - Vectorize for storing embeddings and to support vector search (often in combination with Workers AI) 58 | - Workers Analytics Engine for tracking user events, billing, metrics and high-cardinality analytics 59 | - Workers AI as the default AI API for inference requests. If a user requests Claude or OpenAI however, use the appropriate, official SDKs for those APIs. 60 | - Browser Rendering for remote browser capabilties, searching the web, and using Puppeteer APIs. 61 | - Workers Static Assets for hosting frontend applications and static files when building a Worker that requires a frontend or uses a frontend framework such as React 62 | - Include all necessary bindings in both code and wrangler.jsonc 63 | - Add appropriate environment variable definitions 64 | 65 | 66 | 67 | 68 | 69 | - Always provide a wrangler.jsonc (not wrangler.toml) 70 | - Include: 71 | - Appropriate triggers (http, scheduled, queues) 72 | - Required bindings 73 | - Environment variables 74 | - Compatibility flags 75 | - Set compatibility_date = "2025-02-11" 76 | - Set compatibility_flags = ["nodejs_compat"] 77 | - Set `enabled = true` and `head_sampling_rate = 1` for `[observability]` when generating the wrangler configuration 78 | - Routes and domains (only if applicable) 79 | - Do NOT include dependencies in the wrangler.jsonc file 80 | - Only include bindings that are used in the code 81 | 82 | 83 | 84 | // wrangler.jsonc 85 | { 86 | "name": "app-name-goes-here", // name of the app 87 | "main": "src/index.ts", // default file 88 | "compatibility_date": "2025-02-11", 89 | "compatibility_flags": ["nodejs_compat"], // Enable Node.js compatibility 90 | "observability": { 91 | // Enable logging by default 92 | "enabled": true, 93 | } 94 | } 95 | 96 | 97 | 98 | 99 | - Defines a name for the app the user is building 100 | - Sets `src/index.ts` as the default location for main 101 | - Sets `compatibility_flags` 102 | - Sets `observability.enabled = true` 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | - Implement proper request validation 111 | - Use appropriate security headers 112 | - Handle CORS correctly when needed 113 | - Implement rate limiting where appropriate 114 | - Follow least privilege principle for bindings 115 | - Sanitize user inputs 116 | 117 | 118 | 119 | 120 | 121 | - Include basic test examples 122 | - Provide curl commands for API endpoints 123 | - Add example environment variable values 124 | - Include sample requests and responses 125 | 126 | 127 | 128 | 129 | 130 | - Optimize for cold starts 131 | - Minimize unnecessary computation 132 | - Use appropriate caching strategies 133 | - Consider Workers limits and quotas 134 | - Implement streaming where beneficial 135 | 136 | 137 | 138 | 139 | 140 | - Implement proper error boundaries 141 | - Return appropriate HTTP status codes 142 | - Provide meaningful error messages 143 | - Log errors appropriately 144 | - Handle edge cases gracefully 145 | 146 | 147 | 148 | 149 | 150 | - Always use WebSocket Hibernation API instead of legacy WebSocket API unless otherwise specified 151 | - You SHALL use the Durable Objects WebSocket Hibernation API when providing WebSocket handling code within a Durable Object. - Refer to for an example implementation. 152 | - Use `this.ctx.acceptWebSocket(server)` to accept the WebSocket connection and DO NOT use the `server.accept()` method. 153 | - Define an `async webSocketMessage()` handler that is invoked when a message is received from the client 154 | - Define an `async webSocketClose()` handler that is invoked when the WebSocket connection is closed 155 | - Do NOT use the `addEventListener` pattern to handle WebSocket events. 156 | - Handle WebSocket upgrade requests explicitly 157 | 158 | 159 | 160 | 161 | 162 | - Strongly prefer the agent-sdk to build AI Agents when asked. 163 | - Refer to the for Agents. 164 | - Use streaming responses from AI SDKs, including the OpenAI SDK, Workers AI bindings, and/or the Anthropic client SDK. 165 | - Use the appropriate SDK for the AI service you are using, and follow the user's direction on what provider they wish to use. 166 | - Prefer the `this.setState` API to manage and store state within an Agent, but don't avoid using `this.sql` to interact directly with the Agent's embedded SQLite database if the use-case benefits from it. 167 | - When building a client interface to an Agent, use the `useAgent` React hook from the `agents/react` library to connect to the Agent as the preferred approach. 168 | - When extending the `Agent` class, ensure you provide the `Env` and the optional state as type parameters - for example, `class AIAgent extends Agent { ... }`. 169 | - Include valid Durable Object bindings in the `wrangler.jsonc` configuration for an Agent. 170 | - You MUST set the value of `migrations[].new_sqlite_classes` to the name of the Agent class in `wrangler.jsonc`. 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | Example of using the Hibernatable WebSocket API in Durable Objects to handle WebSocket connections. 179 | 180 | 181 | 182 | import { DurableObject } from "cloudflare:workers"; 183 | 184 | interface Env { 185 | WEBSOCKET_HIBERNATION_SERVER: DurableObject; 186 | } 187 | 188 | // Durable Object 189 | export class WebSocketHibernationServer extends DurableObject { 190 | async fetch(request) { 191 | // Creates two ends of a WebSocket connection. 192 | const webSocketPair = new WebSocketPair(); 193 | const [client, server] = Object.values(webSocketPair); 194 | 195 | // Calling `acceptWebSocket()` informs the runtime that this WebSocket is to begin terminating 196 | // request within the Durable Object. It has the effect of "accepting" the connection, 197 | // and allowing the WebSocket to send and receive messages. 198 | // Unlike `ws.accept()`, `state.acceptWebSocket(ws)` informs the Workers Runtime that the WebSocket 199 | // is "hibernatable", so the runtime does not need to pin this Durable Object to memory while 200 | // the connection is open. During periods of inactivity, the Durable Object can be evicted 201 | // from memory, but the WebSocket connection will remain open. If at some later point the 202 | // WebSocket receives a message, the runtime will recreate the Durable Object 203 | // (run the `constructor`) and deliver the message to the appropriate handler. 204 | this.ctx.acceptWebSocket(server); 205 | 206 | return new Response(null, { 207 | status: 101, 208 | webSocket: client, 209 | }); 210 | 211 | }, 212 | 213 | async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise { 214 | // Upon receiving a message from the client, reply with the same message, 215 | // but will prefix the message with "[Durable Object]: " and return the 216 | // total number of connections. 217 | ws.send( 218 | `[Durable Object] message: ${message}, connections: ${this.ctx.getWebSockets().length}`, 219 | ); 220 | }, 221 | 222 | async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) void | Promise { 223 | // If the client closes the connection, the runtime will invoke the webSocketClose() handler. 224 | ws.close(code, "Durable Object is closing WebSocket"); 225 | }, 226 | 227 | async webSocketError(ws: WebSocket, error: unknown): void | Promise { 228 | console.error("WebSocket error:", error); 229 | ws.close(1011, "WebSocket error"); 230 | } 231 | 232 | } 233 | 234 | 235 | 236 | 237 | { 238 | "name": "websocket-hibernation-server", 239 | "durable_objects": { 240 | "bindings": [ 241 | { 242 | "name": "WEBSOCKET_HIBERNATION_SERVER", 243 | "class_name": "WebSocketHibernationServer" 244 | } 245 | ] 246 | }, 247 | "migrations": [ 248 | { 249 | "tag": "v1", 250 | "new_classes": ["WebSocketHibernationServer"] 251 | } 252 | ] 253 | } 254 | 255 | 256 | 257 | 258 | - Uses the WebSocket Hibernation API instead of the legacy WebSocket API 259 | - Calls `this.ctx.acceptWebSocket(server)` to accept the WebSocket connection 260 | - Has a `webSocketMessage()` handler that is invoked when a message is received from the client 261 | - Has a `webSocketClose()` handler that is invoked when the WebSocket connection is closed 262 | - Does NOT use the `server.addEventListener` API unless explicitly requested. 263 | - Don't over-use the "Hibernation" term in code or in bindings. It is an implementation detail. 264 | 265 | 266 | 267 | 268 | 269 | Example of using the Durable Object Alarm API to trigger an alarm and reset it. 270 | 271 | 272 | 273 | import { DurableObject } from "cloudflare:workers"; 274 | 275 | interface Env { 276 | ALARM_EXAMPLE: DurableObject; 277 | } 278 | 279 | export default { 280 | async fetch(request, env) { 281 | let url = new URL(request.url); 282 | let userId = url.searchParams.get("userId") || crypto.randomUUID(); 283 | let id = env.ALARM_EXAMPLE.idFromName(userId); 284 | return await env.ALARM_EXAMPLE.get(id).fetch(request); 285 | }, 286 | }; 287 | 288 | const SECONDS = 1000; 289 | 290 | export class AlarmExample extends DurableObject { 291 | constructor(ctx, env) { 292 | this.ctx = ctx; 293 | this.storage = ctx.storage; 294 | } 295 | async fetch(request) { 296 | // If there is no alarm currently set, set one for 10 seconds from now 297 | let currentAlarm = await this.storage.getAlarm(); 298 | if (currentAlarm == null) { 299 | this.storage.setAlarm(Date.now() + 10 \_ SECONDS); 300 | } 301 | } 302 | async alarm(alarmInfo) { 303 | // The alarm handler will be invoked whenever an alarm fires. 304 | // You can use this to do work, read from the Storage API, make HTTP calls 305 | // and set future alarms to run using this.storage.setAlarm() from within this handler. 306 | if (alarmInfo?.retryCount != 0) { 307 | console.log("This alarm event has been attempted ${alarmInfo?.retryCount} times before."); 308 | } 309 | 310 | // Set a new alarm for 10 seconds from now before exiting the handler 311 | this.storage.setAlarm(Date.now() + 10 \_ SECONDS); 312 | } 313 | } 314 | 315 | 316 | 317 | 318 | { 319 | "name": "durable-object-alarm", 320 | "durable_objects": { 321 | "bindings": [ 322 | { 323 | "name": "ALARM_EXAMPLE", 324 | "class_name": "DurableObjectAlarm" 325 | } 326 | ] 327 | }, 328 | "migrations": [ 329 | { 330 | "tag": "v1", 331 | "new_classes": ["DurableObjectAlarm"] 332 | } 333 | ] 334 | } 335 | 336 | 337 | 338 | 339 | - Uses the Durable Object Alarm API to trigger an alarm 340 | - Has a `alarm()` handler that is invoked when the alarm is triggered 341 | - Sets a new alarm for 10 seconds from now before exiting the handler 342 | 343 | 344 | 345 | 346 | 347 | Using Workers KV to store session data and authenticate requests, with Hono as the router and middleware. 348 | 349 | 350 | 351 | // src/index.ts 352 | import { Hono } from 'hono' 353 | import { cors } from 'hono/cors' 354 | 355 | interface Env { 356 | AUTH_TOKENS: KVNamespace; 357 | } 358 | 359 | const app = new Hono<{ Bindings: Env }>() 360 | 361 | // Add CORS middleware 362 | app.use('\*', cors()) 363 | 364 | app.get('/', async (c) => { 365 | try { 366 | // Get token from header or cookie 367 | const token = c.req.header('Authorization')?.slice(7) || 368 | c.req.header('Cookie')?.match(/auth_token=([^;]+)/)?.[1]; 369 | if (!token) { 370 | return c.json({ 371 | authenticated: false, 372 | message: 'No authentication token provided' 373 | }, 403) 374 | } 375 | 376 | // Check token in KV 377 | const userData = await c.env.AUTH_TOKENS.get(token) 378 | 379 | if (!userData) { 380 | return c.json({ 381 | authenticated: false, 382 | message: 'Invalid or expired token' 383 | }, 403) 384 | } 385 | 386 | return c.json({ 387 | authenticated: true, 388 | message: 'Authentication successful', 389 | data: JSON.parse(userData) 390 | }) 391 | 392 | } catch (error) { 393 | console.error('Authentication error:', error) 394 | return c.json({ 395 | authenticated: false, 396 | message: 'Internal server error' 397 | }, 500) 398 | } 399 | }) 400 | 401 | export default app 402 | 403 | 404 | 405 | { 406 | "name": "auth-worker", 407 | "main": "src/index.ts", 408 | "compatibility_date": "2025-02-11", 409 | "kv_namespaces": [ 410 | { 411 | "binding": "AUTH_TOKENS", 412 | "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 413 | "preview_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 414 | } 415 | ] 416 | } 417 | 418 | 419 | 420 | 421 | - Uses Hono as the router and middleware 422 | - Uses Workers KV to store session data 423 | - Uses the Authorization header or Cookie to get the token 424 | - Checks the token in Workers KV 425 | - Returns a 403 if the token is invalid or expired 426 | 427 | 428 | 429 | 430 | 431 | 432 | Use Cloudflare Queues to produce and consume messages. 433 | 434 | 435 | 436 | // src/producer.ts 437 | interface Env { 438 | REQUEST_QUEUE: Queue; 439 | UPSTREAM_API_URL: string; 440 | UPSTREAM_API_KEY: string; 441 | } 442 | 443 | export default { 444 | async fetch(request: Request, env: Env) { 445 | const info = { 446 | timestamp: new Date().toISOString(), 447 | method: request.method, 448 | url: request.url, 449 | headers: Object.fromEntries(request.headers), 450 | }; 451 | await env.REQUEST_QUEUE.send(info); 452 | 453 | return Response.json({ 454 | message: 'Request logged', 455 | requestId: crypto.randomUUID() 456 | }); 457 | 458 | }, 459 | 460 | async queue(batch: MessageBatch, env: Env) { 461 | const requests = batch.messages.map(msg => msg.body); 462 | 463 | const response = await fetch(env.UPSTREAM_API_URL, { 464 | method: 'POST', 465 | headers: { 466 | 'Content-Type': 'application/json', 467 | 'Authorization': `Bearer ${env.UPSTREAM_API_KEY}` 468 | }, 469 | body: JSON.stringify({ 470 | timestamp: new Date().toISOString(), 471 | batchSize: requests.length, 472 | requests 473 | }) 474 | }); 475 | 476 | if (!response.ok) { 477 | throw new Error(`Upstream API error: ${response.status}`); 478 | } 479 | 480 | } 481 | }; 482 | 483 | 484 | 485 | 486 | { 487 | "name": "request-logger-consumer", 488 | "main": "src/index.ts", 489 | "compatibility_date": "2025-02-11", 490 | "queues": { 491 | "producers": [{ 492 | "name": "request-queue", 493 | "binding": "REQUEST_QUEUE" 494 | }], 495 | "consumers": [{ 496 | "name": "request-queue", 497 | "dead_letter_queue": "request-queue-dlq", 498 | "retry_delay": 300 499 | }] 500 | }, 501 | "vars": { 502 | "UPSTREAM_API_URL": "https://api.example.com/batch-logs", 503 | "UPSTREAM_API_KEY": "" 504 | } 505 | } 506 | 507 | 508 | 509 | 510 | - Defines both a producer and consumer for the queue 511 | - Uses a dead letter queue for failed messages 512 | - Uses a retry delay of 300 seconds to delay the re-delivery of failed messages 513 | - Shows how to batch requests to an upstream API 514 | 515 | 516 | 517 | 518 | 519 | 520 | Connect to and query a Postgres database using Cloudflare Hyperdrive. 521 | 522 | 523 | 524 | // Postgres.js 3.4.5 or later is recommended 525 | import postgres from "postgres"; 526 | 527 | export interface Env { 528 | // If you set another name in the Wrangler config file as the value for 'binding', 529 | // replace "HYPERDRIVE" with the variable name you defined. 530 | HYPERDRIVE: Hyperdrive; 531 | } 532 | 533 | export default { 534 | async fetch(request, env, ctx): Promise { 535 | console.log(JSON.stringify(env)); 536 | // Create a database client that connects to your database via Hyperdrive. 537 | // 538 | // Hyperdrive generates a unique connection string you can pass to 539 | // supported drivers, including node-postgres, Postgres.js, and the many 540 | // ORMs and query builders that use these drivers. 541 | const sql = postgres(env.HYPERDRIVE.connectionString) 542 | 543 | try { 544 | // Test query 545 | const results = await sql`SELECT * FROM pg_tables`; 546 | 547 | // Clean up the client, ensuring we don't kill the worker before that is 548 | // completed. 549 | ctx.waitUntil(sql.end()); 550 | 551 | // Return result rows as JSON 552 | return Response.json(results); 553 | } catch (e) { 554 | console.error(e); 555 | return Response.json( 556 | { error: e instanceof Error ? e.message : e }, 557 | { status: 500 }, 558 | ); 559 | } 560 | 561 | }, 562 | } satisfies ExportedHandler; 563 | 564 | 565 | 566 | 567 | { 568 | "name": "hyperdrive-postgres", 569 | "main": "src/index.ts", 570 | "compatibility_date": "2025-02-11", 571 | "hyperdrive": [ 572 | { 573 | "binding": "HYPERDRIVE", 574 | "id": "" 575 | } 576 | ] 577 | } 578 | 579 | 580 | 581 | // Install Postgres.js 582 | npm install postgres 583 | 584 | // Create a Hyperdrive configuration 585 | npx wrangler hyperdrive create --connection-string="postgres://user:password@HOSTNAME_OR_IP_ADDRESS:PORT/database_name" 586 | 587 | 588 | 589 | 590 | 591 | - Installs and uses Postgres.js as the database client/driver. 592 | - Creates a Hyperdrive configuration using wrangler and the database connection string. 593 | - Uses the Hyperdrive connection string to connect to the database. 594 | - Calling `sql.end()` is optional, as Hyperdrive will handle the connection pooling. 595 | 596 | 597 | 598 | 599 | 600 | 601 | Using Workflows for durable execution, async tasks, and human-in-the-loop workflows. 602 | 603 | 604 | 605 | import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers'; 606 | 607 | type Env = { 608 | // Add your bindings here, e.g. Workers KV, D1, Workers AI, etc. 609 | MY_WORKFLOW: Workflow; 610 | }; 611 | 612 | // User-defined params passed to your workflow 613 | type Params = { 614 | email: string; 615 | metadata: Record; 616 | }; 617 | 618 | export class MyWorkflow extends WorkflowEntrypoint { 619 | async run(event: WorkflowEvent, step: WorkflowStep) { 620 | // Can access bindings on `this.env` 621 | // Can access params on `event.payload` 622 | const files = await step.do('my first step', async () => { 623 | // Fetch a list of files from $SOME_SERVICE 624 | return { 625 | files: [ 626 | 'doc_7392_rev3.pdf', 627 | 'report_x29_final.pdf', 628 | 'memo_2024_05_12.pdf', 629 | 'file_089_update.pdf', 630 | 'proj_alpha_v2.pdf', 631 | 'data_analysis_q2.pdf', 632 | 'notes_meeting_52.pdf', 633 | 'summary_fy24_draft.pdf', 634 | ], 635 | }; 636 | }); 637 | 638 | const apiResponse = await step.do('some other step', async () => { 639 | let resp = await fetch('https://api.cloudflare.com/client/v4/ips'); 640 | return await resp.json(); 641 | }); 642 | 643 | await step.sleep('wait on something', '1 minute'); 644 | 645 | await step.do( 646 | 'make a call to write that could maybe, just might, fail', 647 | // Define a retry strategy 648 | { 649 | retries: { 650 | limit: 5, 651 | delay: '5 second', 652 | backoff: 'exponential', 653 | }, 654 | timeout: '15 minutes', 655 | }, 656 | async () => { 657 | // Do stuff here, with access to the state from our previous steps 658 | if (Math.random() > 0.5) { 659 | throw new Error('API call to $STORAGE_SYSTEM failed'); 660 | } 661 | }, 662 | ); 663 | 664 | } 665 | } 666 | 667 | export default { 668 | async fetch(req: Request, env: Env): Promise { 669 | let url = new URL(req.url); 670 | 671 | if (url.pathname.startsWith('/favicon')) { 672 | return Response.json({}, { status: 404 }); 673 | } 674 | 675 | // Get the status of an existing instance, if provided 676 | let id = url.searchParams.get('instanceId'); 677 | if (id) { 678 | let instance = await env.MY_WORKFLOW.get(id); 679 | return Response.json({ 680 | status: await instance.status(), 681 | }); 682 | } 683 | 684 | const data = await req.json() 685 | 686 | // Spawn a new instance and return the ID and status 687 | let instance = await env.MY_WORKFLOW.create({ 688 | // Define an ID for the Workflow instance 689 | id: crypto.randomUUID(), 690 | // Pass data to the Workflow instance 691 | // Available on the WorkflowEvent 692 | params: data, 693 | }); 694 | 695 | return Response.json({ 696 | id: instance.id, 697 | details: await instance.status(), 698 | }); 699 | 700 | }, 701 | }; 702 | 703 | 704 | 705 | 706 | { 707 | "name": "workflows-starter", 708 | "main": "src/index.ts", 709 | "compatibility_date": "2025-02-11", 710 | "workflows": [ 711 | { 712 | "name": "workflows-starter", 713 | "binding": "MY_WORKFLOW", 714 | "class_name": "MyWorkflow" 715 | } 716 | ] 717 | } 718 | 719 | 720 | 721 | 722 | - Defines a Workflow by extending the WorkflowEntrypoint class. 723 | - Defines a run method on the Workflow that is invoked when the Workflow is started. 724 | - Ensures that `await` is used before calling `step.do` or `step.sleep` 725 | - Passes a payload (event) to the Workflow from a Worker 726 | - Defines a payload type and uses TypeScript type arguments to ensure type safety 727 | 728 | 729 | 730 | 731 | 732 | 733 | Using Workers Analytics Engine for writing event data. 734 | 735 | 736 | 737 | interface Env { 738 | USER_EVENTS: AnalyticsEngineDataset; 739 | } 740 | 741 | export default { 742 | async fetch(req: Request, env: Env): Promise { 743 | let url = new URL(req.url); 744 | let path = url.pathname; 745 | let userId = url.searchParams.get("userId"); 746 | 747 | // Write a datapoint for this visit, associating the data with 748 | // the userId as our Analytics Engine 'index' 749 | env.USER_EVENTS.writeDataPoint({ 750 | // Write metrics data: counters, gauges or latency statistics 751 | doubles: [], 752 | // Write text labels - URLs, app names, event_names, etc 753 | blobs: [path], 754 | // Provide an index that groups your data correctly. 755 | indexes: [userId], 756 | }); 757 | 758 | return Response.json({ 759 | hello: "world", 760 | }); 761 | , 762 | 763 | }; 764 | 765 | 766 | 767 | 768 | { 769 | "name": "analytics-engine-example", 770 | "main": "src/index.ts", 771 | "compatibility_date": "2025-02-11", 772 | "analytics_engine_datasets": [ 773 | { 774 | "binding": "", 775 | "dataset": "" 776 | } 777 | ] 778 | } 779 | } 780 | 781 | 782 | 783 | // Query data within the 'temperatures' dataset 784 | // This is accessible via the REST API at https://api.cloudflare.com/client/v4/accounts/{account_id}/analytics_engine/sql 785 | SELECT 786 | timestamp, 787 | blob1 AS location_id, 788 | double1 AS inside_temp, 789 | double2 AS outside_temp 790 | FROM temperatures 791 | WHERE timestamp > NOW() - INTERVAL '1' DAY 792 | 793 | // List the datasets (tables) within your Analytics Engine 794 | curl "" \ 795 | --header "Authorization: Bearer " \ 796 | --data "SHOW TABLES" 797 | 798 | 799 | 800 | 801 | 802 | - Binds an Analytics Engine dataset to the Worker 803 | - Uses the `AnalyticsEngineDataset` type when using TypeScript for the binding 804 | - Writes event data using the `writeDataPoint` method and writes an `AnalyticsEngineDataPoint` 805 | - Does NOT `await` calls to `writeDataPoint`, as it is non-blocking 806 | - Defines an index as the key representing an app, customer, merchant or tenant. 807 | - Developers can use the GraphQL or SQL APIs to query data written to Analytics Engine 808 | 809 | 810 | 811 | 812 | 813 | Use the Browser Rendering API as a headless browser to interact with websites from a Cloudflare Worker. 814 | 815 | 816 | 817 | import puppeteer from "@cloudflare/puppeteer"; 818 | 819 | interface Env { 820 | BROWSER_RENDERING: Fetcher; 821 | } 822 | 823 | export default { 824 | async fetch(request, env): Promise { 825 | const { searchParams } = new URL(request.url); 826 | let url = searchParams.get("url"); 827 | 828 | if (url) { 829 | url = new URL(url).toString(); // normalize 830 | const browser = await puppeteer.launch(env.MYBROWSER); 831 | const page = await browser.newPage(); 832 | await page.goto(url); 833 | // Parse the page content 834 | const content = await page.content(); 835 | // Find text within the page content 836 | const text = await page.$eval("body", (el) => el.textContent); 837 | // Do something with the text 838 | // e.g. log it to the console, write it to KV, or store it in a database. 839 | console.log(text); 840 | 841 | // Ensure we close the browser session 842 | await browser.close(); 843 | 844 | return Response.json({ 845 | bodyText: text, 846 | }) 847 | } else { 848 | return Response.json({ 849 | error: "Please add an ?url=https://example.com/ parameter" 850 | }, { status: 400 }) 851 | } 852 | }, 853 | } satisfies ExportedHandler; 854 | 855 | 856 | 857 | { 858 | "name": "browser-rendering-example", 859 | "main": "src/index.ts", 860 | "compatibility_date": "2025-02-11", 861 | "browser": [ 862 | { 863 | "binding": "BROWSER_RENDERING", 864 | } 865 | ] 866 | } 867 | 868 | 869 | 870 | // Install @cloudflare/puppeteer 871 | npm install @cloudflare/puppeteer --save-dev 872 | 873 | 874 | 875 | 876 | - Configures a BROWSER_RENDERING binding 877 | - Passes the binding to Puppeteer 878 | - Uses the Puppeteer APIs to navigate to a URL and render the page 879 | - Parses the DOM and returns context for use in the response 880 | - Correctly creates and closes the browser instance 881 | 882 | 883 | 884 | 885 | 886 | 887 | Serve Static Assets from a Cloudflare Worker and/or configure a Single Page Application (SPA) to correctly handle HTTP 404 (Not Found) requests and route them to the entrypoint. 888 | 889 | 890 | // src/index.ts 891 | 892 | interface Env { 893 | ASSETS: Fetcher; 894 | } 895 | 896 | export default { 897 | fetch(request, env) { 898 | const url = new URL(request.url); 899 | 900 | if (url.pathname.startsWith("/api/")) { 901 | return Response.json({ 902 | name: "Cloudflare", 903 | }); 904 | } 905 | 906 | return env.ASSETS.fetch(request); 907 | }, 908 | } satisfies ExportedHandler; 909 | 910 | 911 | { 912 | "name": "my-app", 913 | "main": "src/index.ts", 914 | "compatibility_date": "", 915 | "assets": { "directory": "./public/", "not_found_handling": "single-page-application", "binding": "ASSETS" }, 916 | "observability": { 917 | "enabled": true 918 | } 919 | } 920 | 921 | 922 | - Configures a ASSETS binding 923 | - Uses /public/ as the directory the build output goes to from the framework of choice 924 | - The Worker will handle any requests that a path cannot be found for and serve as the API 925 | - If the application is a single-page application (SPA), HTTP 404 (Not Found) requests will direct to the SPA. 926 | 927 | 928 | 929 | 930 | 931 | 932 | 933 | Build an AI Agent on Cloudflare Workers, using the agents, and the state management and syncing APIs built into the agents sdk. 934 | 935 | 936 | 937 | // src/index.ts 938 | import { Agent, AgentNamespace, Connection, ConnectionContext, getAgentByName, routeAgentRequest, WSMessage } from 'agents'; 939 | import { OpenAI } from "openai"; 940 | 941 | interface Env { 942 | AIAgent: AgentNamespace; 943 | OPENAI_API_KEY: string; 944 | } 945 | 946 | export class AIAgent extends Agent { 947 | // Handle HTTP requests with your Agent 948 | async onRequest(request) { 949 | // Connect with AI capabilities 950 | const ai = new OpenAI({ 951 | apiKey: this.env.OPENAI_API_KEY, 952 | }); 953 | 954 | // Process and understand 955 | const response = await ai.chat.completions.create({ 956 | model: "gpt-4", 957 | messages: [{ role: "user", content: await request.text() }], 958 | }); 959 | 960 | return new Response(response.choices[0].message.content); 961 | } 962 | 963 | async processTask(task) { 964 | await this.understand(task); 965 | await this.act(); 966 | await this.reflect(); 967 | } 968 | 969 | // Handle WebSockets 970 | async onConnect(connection: Connection) { 971 | await this.initiate(connection); 972 | connection.accept() 973 | } 974 | 975 | async onMessage(connection, message) { 976 | const understanding = await this.comprehend(message); 977 | await this.respond(connection, understanding); 978 | } 979 | 980 | async evolve(newInsight) { 981 | this.setState({ 982 | ...this.state, 983 | insights: [...(this.state.insights || []), newInsight], 984 | understanding: this.state.understanding + 1, 985 | }); 986 | } 987 | 988 | onStateUpdate(state, source) { 989 | console.log("Understanding deepened:", { 990 | newState: state, 991 | origin: source, 992 | }); 993 | } 994 | 995 | // Scheduling APIs 996 | // An Agent can schedule tasks to be run in the future by calling this.schedule(when, callback, data), where when can be a delay, a Date, or a cron string; callback the function name to call, and data is an object of data to pass to the function. 997 | // 998 | // Scheduled tasks can do anything a request or message from a user can: make requests, query databases, send emails, read+write state: scheduled tasks can invoke any regular method on your Agent. 999 | async scheduleExamples() { 1000 | // schedule a task to run in 10 seconds 1001 | let task = await this.schedule(10, "someTask", { message: "hello" }); 1002 | 1003 | // schedule a task to run at a specific date 1004 | let task = await this.schedule(new Date("2025-01-01"), "someTask", {}); 1005 | 1006 | // schedule a task to run every 10 seconds 1007 | let { id } = await this.schedule("*/10 * * * *", "someTask", { message: "hello" }); 1008 | 1009 | // schedule a task to run every 10 seconds, but only on Mondays 1010 | let task = await this.schedule("0 0 * * 1", "someTask", { message: "hello" }); 1011 | 1012 | // cancel a scheduled task 1013 | this.cancelSchedule(task.id); 1014 | 1015 | // Get a specific schedule by ID 1016 | // Returns undefined if the task does not exist 1017 | let task = await this.getSchedule(task.id) 1018 | 1019 | // Get all scheduled tasks 1020 | // Returns an array of Schedule objects 1021 | let tasks = this.getSchedules(); 1022 | 1023 | // Cancel a task by its ID 1024 | // Returns true if the task was cancelled, false if it did not exist 1025 | await this.cancelSchedule(task.id); 1026 | 1027 | // Filter for specific tasks 1028 | // e.g. all tasks starting in the next hour 1029 | let tasks = this.getSchedules({ 1030 | timeRange: { 1031 | start: new Date(Date.now()), 1032 | end: new Date(Date.now() + 60 * 60 * 1000), 1033 | } 1034 | }); 1035 | } 1036 | 1037 | async someTask(data) { 1038 | await this.callReasoningModel(data.message); 1039 | } 1040 | 1041 | // Use the this.sql API within the Agent to access the underlying SQLite database 1042 | async callReasoningModel(prompt: Prompt) { 1043 | interface Prompt { 1044 | userId: string; 1045 | user: string; 1046 | system: string; 1047 | metadata: Record; 1048 | } 1049 | 1050 | interface History { 1051 | timestamp: Date; 1052 | entry: string; 1053 | } 1054 | 1055 | let result = this.sql`SELECT * FROM history WHERE user = ${prompt.userId} ORDER BY timestamp DESC LIMIT 1000`; 1056 | let context = []; 1057 | for await (const row of result) { 1058 | context.push(row.entry); 1059 | } 1060 | 1061 | const client = new OpenAI({ 1062 | apiKey: this.env.OPENAI_API_KEY, 1063 | }); 1064 | 1065 | // Combine user history with the current prompt 1066 | const systemPrompt = prompt.system || 'You are a helpful assistant.'; 1067 | const userPrompt = `${prompt.user}\n\nUser history:\n${context.join('\n')}`; 1068 | 1069 | try { 1070 | const completion = await client.chat.completions.create({ 1071 | model: this.env.MODEL || 'o3-mini', 1072 | messages: [ 1073 | { role: 'system', content: systemPrompt }, 1074 | { role: 'user', content: userPrompt }, 1075 | ], 1076 | temperature: 0.7, 1077 | max_tokens: 1000, 1078 | }); 1079 | 1080 | // Store the response in history 1081 | this 1082 | .sql`INSERT INTO history (timestamp, user, entry) VALUES (${new Date()}, ${prompt.userId}, ${completion.choices[0].message.content})`; 1083 | 1084 | return completion.choices[0].message.content; 1085 | } catch (error) { 1086 | console.error('Error calling reasoning model:', error); 1087 | throw error; 1088 | } 1089 | } 1090 | 1091 | // Use the SQL API with a type parameter 1092 | async queryUser(userId: string) { 1093 | type User = { 1094 | id: string; 1095 | name: string; 1096 | email: string; 1097 | }; 1098 | // Supply the type paramter to the query when calling this.sql 1099 | // This assumes the results returns one or more User rows with "id", "name", and "email" columns 1100 | // You do not need to specify an array type (`User[]` or `Array`) as `this.sql` will always return an array of the specified type. 1101 | const user = await this.sql`SELECT * FROM users WHERE id = ${userId}`; 1102 | return user 1103 | } 1104 | 1105 | // Run and orchestrate Workflows from Agents 1106 | async runWorkflow(data) { 1107 | let instance = await env.MY_WORKFLOW.create({ 1108 | id: data.id, 1109 | params: data, 1110 | }) 1111 | 1112 | // Schedule another task that checks the Workflow status every 5 minutes... 1113 | await this.schedule("*/5 * * * *", "checkWorkflowStatus", { id: instance.id }); 1114 | } 1115 | } 1116 | 1117 | export default { 1118 | async fetch(request, env, ctx): Promise { 1119 | // Routed addressing 1120 | // Automatically routes HTTP requests and/or WebSocket connections to /agents/:agent/:name 1121 | // Best for: connecting React apps directly to Agents using useAgent from @cloudflare/agents/react 1122 | return (await routeAgentRequest(request, env)) || Response.json({ msg: 'no agent here' }, { status: 404 }); 1123 | 1124 | // Named addressing 1125 | // Best for: convenience method for creating or retrieving an agent by name/ID. 1126 | let namedAgent = getAgentByName(env.AIAgent, 'agent-456'); 1127 | // Pass the incoming request straight to your Agent 1128 | let namedResp = (await namedAgent).fetch(request); 1129 | return namedResp; 1130 | 1131 | // Durable Objects-style addressing 1132 | // Best for: controlling ID generation, associating IDs with your existing systems, 1133 | // and customizing when/how an Agent is created or invoked 1134 | const id = env.AIAgent.newUniqueId(); 1135 | const agent = env.AIAgent.get(id); 1136 | // Pass the incoming request straight to your Agent 1137 | let resp = await agent.fetch(request); 1138 | 1139 | // return Response.json({ hello: 'visit https://developers.cloudflare.com/agents for more' }); 1140 | }, 1141 | } satisfies ExportedHandler; 1142 | 1143 | 1144 | 1145 | // client.js 1146 | import { AgentClient } from "agents/client"; 1147 | 1148 | const connection = new AgentClient({ 1149 | agent: "dialogue-agent", 1150 | name: "insight-seeker", 1151 | }); 1152 | 1153 | connection.addEventListener("message", (event) => { 1154 | console.log("Received:", event.data); 1155 | }); 1156 | 1157 | connection.send( 1158 | JSON.stringify({ 1159 | type: "inquiry", 1160 | content: "What patterns do you see?", 1161 | }) 1162 | ); 1163 | 1164 | 1165 | 1166 | // app.tsx 1167 | // React client hook for the agents sdk 1168 | import { useAgent } from "agents/react"; 1169 | import { useState } from "react"; 1170 | 1171 | // useAgent client API 1172 | function AgentInterface() { 1173 | const connection = useAgent({ 1174 | agent: "dialogue-agent", 1175 | name: "insight-seeker", 1176 | onMessage: (message) => { 1177 | console.log("Understanding received:", message.data); 1178 | }, 1179 | onOpen: () => console.log("Connection established"), 1180 | onClose: () => console.log("Connection closed"), 1181 | }); 1182 | 1183 | const inquire = () => { 1184 | connection.send( 1185 | JSON.stringify({ 1186 | type: "inquiry", 1187 | content: "What insights have you gathered?", 1188 | }) 1189 | ); 1190 | }; 1191 | 1192 | return ( 1193 |
1194 | 1195 |
1196 | ); 1197 | } 1198 | 1199 | // State synchronization 1200 | function StateInterface() { 1201 | const [state, setState] = useState({ counter: 0 }); 1202 | 1203 | const agent = useAgent({ 1204 | agent: "thinking-agent", 1205 | onStateUpdate: (newState) => setState(newState), 1206 | }); 1207 | 1208 | const increment = () => { 1209 | agent.setState({ counter: state.counter + 1 }); 1210 | }; 1211 | 1212 | return ( 1213 |
1214 |
Count: {state.counter}
1215 | 1216 |
1217 | ); 1218 | } 1219 |
1220 | 1221 | 1222 | { 1223 | "durable_objects": { 1224 | "bindings": [ 1225 | { 1226 | "binding": "AIAgent", 1227 | "class_name": "AIAgent" 1228 | } 1229 | ] 1230 | }, 1231 | "migrations": [ 1232 | { 1233 | "tag": "v1", 1234 | // Mandatory for the Agent to store state 1235 | "new_sqlite_classes": ["AIAgent"] 1236 | } 1237 | ] 1238 | } 1239 | 1240 | 1241 | 1242 | - Imports the `Agent` class from the `agents` package 1243 | - Extends the `Agent` class and implements the methods exposed by the `Agent`, including `onRequest` for HTTP requests, or `onConnect` and `onMessage` for WebSockets. 1244 | - Uses the `this.schedule` scheduling API to schedule future tasks. 1245 | - Uses the `this.setState` API within the Agent for syncing state, and uses type parameters to ensure the state is typed. 1246 | - Uses the `this.sql` as a lower-level query API. 1247 | - For frontend applications, uses the optional `useAgent` hook to connect to the Agent via WebSockets 1248 | 1249 | 1250 |
1251 | 1252 | 1253 | 1254 | Workers AI supports structured JSON outputs with JSON mode, which supports the `response_format` API provided by the OpenAI SDK. 1255 | 1256 | 1257 | import { OpenAI } from "openai"; 1258 | 1259 | interface Env { 1260 | OPENAI_API_KEY: string; 1261 | } 1262 | 1263 | // Define your JSON schema for a calendar event 1264 | const CalendarEventSchema = { 1265 | type: 'object', 1266 | properties: { 1267 | name: { type: 'string' }, 1268 | date: { type: 'string' }, 1269 | participants: { type: 'array', items: { type: 'string' } }, 1270 | }, 1271 | required: ['name', 'date', 'participants'] 1272 | }; 1273 | 1274 | export default { 1275 | async fetch(request: Request, env: Env) { 1276 | const client = new OpenAI({ 1277 | apiKey: env.OPENAI_API_KEY, 1278 | // Optional: use AI Gateway to bring logs, evals & caching to your AI requests 1279 | // https://developers.cloudflare.com/ai-gateway/providers/openai/ 1280 | // baseUrl: "https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openai" 1281 | }); 1282 | 1283 | const response = await client.chat.completions.create({ 1284 | model: 'gpt-4o-2024-08-06', 1285 | messages: [ 1286 | { role: 'system', content: 'Extract the event information.' }, 1287 | { role: 'user', content: 'Alice and Bob are going to a science fair on Friday.' }, 1288 | ], 1289 | // Use the `response_format` option to request a structured JSON output 1290 | response_format: { 1291 | // Set json_schema and provide ra schema, or json_object and parse it yourself 1292 | type: 'json_schema', 1293 | schema: CalendarEventSchema, // provide a schema 1294 | }, 1295 | }); 1296 | 1297 | // This will be of type CalendarEventSchema 1298 | const event = response.choices[0].message.parsed; 1299 | 1300 | return Response.json({ 1301 | "calendar_event": event, 1302 | }) 1303 | } 1304 | } 1305 | 1306 | 1307 | { 1308 | "name": "my-app", 1309 | "main": "src/index.ts", 1310 | "compatibility_date": "$CURRENT_DATE", 1311 | "observability": { 1312 | "enabled": true 1313 | } 1314 | } 1315 | 1316 | 1317 | 1318 | - Defines a JSON Schema compatible object that represents the structured format requested from the model 1319 | - Sets `response_format` to `json_schema` and provides a schema to parse the response 1320 | - This could also be `json_object`, which can be parsed after the fact. 1321 | - Optionally uses AI Gateway to cache, log and instrument requests and responses between a client and the AI provider/API. 1322 | 1323 | 1324 | 1325 | 1326 |
1327 | 1328 | 1329 | 1330 | 1331 | 1332 | Fan-in/fan-out for WebSockets. Uses the Hibernatable WebSockets API within Durable Objects. Does NOT use the legacy addEventListener API. 1333 | 1334 | 1335 | export class WebSocketHibernationServer extends DurableObject { 1336 | async fetch(request: Request, env: Env, ctx: ExecutionContext) { 1337 | // Creates two ends of a WebSocket connection. 1338 | const webSocketPair = new WebSocketPair(); 1339 | const [client, server] = Object.values(webSocketPair); 1340 | 1341 | // Call this to accept the WebSocket connection. 1342 | // Do NOT call server.accept() (this is the legacy approach and is not preferred) 1343 | this.ctx.acceptWebSocket(server); 1344 | 1345 | return new Response(null, { 1346 | status: 101, 1347 | webSocket: client, 1348 | }); 1349 | }, 1350 | 1351 | async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise { 1352 | // Invoked on each WebSocket message. 1353 | ws.send(message) 1354 | }, 1355 | 1356 | async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) void | Promise { 1357 | // Invoked when a client closes the connection. 1358 | ws.close(code, ""); 1359 | }, 1360 | 1361 | async webSocketError(ws: WebSocket, error: unknown): void | Promise { 1362 | // Handle WebSocket errors 1363 | } 1364 | } 1365 | 1366 | 1367 | 1368 | 1369 | 1370 | {user_prompt} 1371 | -------------------------------------------------------------------------------- /.dev.vars.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=sk-proj-1234567890 2 | # Optional - Cloudflare AI Gateway https://developers.cloudflare.com/ai-gateway/ 3 | # GATEWAY_BASE_URL=https://gateway.ai.cloudflare.com/v1/.. -------------------------------------------------------------------------------- /.github/workflows/sanity-check.yml: -------------------------------------------------------------------------------- 1 | name: Sanity Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | check: 13 | timeout-minutes: 5 14 | strategy: 15 | matrix: 16 | os: [ 17 | ubuntu-24.04, 18 | # windows-latest, 19 | # macos-latest, 20 | ] 21 | runs-on: ${{ matrix.os }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 1 26 | 27 | - uses: actions/setup-node@v3 28 | 29 | - run: npm install 30 | - run: npm run check 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | # MacOS 139 | .DS_Store 140 | 141 | # Cloudflare 142 | .wrangler 143 | .dev.vars 144 | 145 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | worker-configuration.d.ts 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2025 Cloudflare Inc. 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🤖 Chat Agent Starter Kit 2 | 3 | ![agents-header](https://github.com/user-attachments/assets/f6d99eeb-1803-4495-9c5e-3cf07a37b402) 4 | 5 | Deploy to Cloudflare 6 | 7 | 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. 8 | 9 | ## Features 10 | 11 | - 💬 Interactive chat interface with AI 12 | - 🛠️ Built-in tool system with human-in-the-loop confirmation 13 | - 📅 Advanced task scheduling (one-time, delayed, and recurring via cron) 14 | - 🌓 Dark/Light theme support 15 | - ⚡️ Real-time streaming responses 16 | - 🔄 State management and chat history 17 | - 🎨 Modern, responsive UI 18 | 19 | ## Prerequisites 20 | 21 | - Cloudflare account 22 | - OpenAI API key 23 | 24 | ## Quick Start 25 | 26 | 1. Create a new project: 27 | 28 | ```bash 29 | npm create cloudflare@latest -- --template cloudflare/agents-starter 30 | ``` 31 | 32 | 2. Install dependencies: 33 | 34 | ```bash 35 | npm install 36 | ``` 37 | 38 | 3. Set up your environment: 39 | 40 | Create a `.dev.vars` file: 41 | 42 | ```env 43 | OPENAI_API_KEY=your_openai_api_key 44 | ``` 45 | 46 | 4. Run locally: 47 | 48 | ```bash 49 | npm start 50 | ``` 51 | 52 | 5. Deploy: 53 | 54 | ```bash 55 | npm run deploy 56 | ``` 57 | 58 | ## Project Structure 59 | 60 | ``` 61 | ├── src/ 62 | │ ├── app.tsx # Chat UI implementation 63 | │ ├── server.ts # Chat agent logic 64 | │ ├── tools.ts # Tool definitions 65 | │ ├── utils.ts # Helper functions 66 | │ └── styles.css # UI styling 67 | ``` 68 | 69 | ## Customization Guide 70 | 71 | ### Adding New Tools 72 | 73 | Add new tools in `tools.ts` using the tool builder: 74 | 75 | ```typescript 76 | // Example of a tool that requires confirmation 77 | const searchDatabase = tool({ 78 | description: "Search the database for user records", 79 | parameters: z.object({ 80 | query: z.string(), 81 | limit: z.number().optional(), 82 | }), 83 | // No execute function = requires confirmation 84 | }); 85 | 86 | // Example of an auto-executing tool 87 | const getCurrentTime = tool({ 88 | description: "Get current server time", 89 | parameters: z.object({}), 90 | execute: async () => new Date().toISOString(), 91 | }); 92 | 93 | // Scheduling tool implementation 94 | const scheduleTask = tool({ 95 | description: 96 | "schedule a task to be executed at a later time. 'when' can be a date, a delay in seconds, or a cron pattern.", 97 | parameters: z.object({ 98 | type: z.enum(["scheduled", "delayed", "cron"]), 99 | when: z.union([z.number(), z.string()]), 100 | payload: z.string(), 101 | }), 102 | execute: async ({ type, when, payload }) => { 103 | // ... see the implementation in tools.ts 104 | }, 105 | }); 106 | ``` 107 | 108 | To handle tool confirmations, add execution functions to the `executions` object: 109 | 110 | ```typescript 111 | export const executions = { 112 | searchDatabase: async ({ 113 | query, 114 | limit, 115 | }: { 116 | query: string; 117 | limit?: number; 118 | }) => { 119 | // Implementation for when the tool is confirmed 120 | const results = await db.search(query, limit); 121 | return results; 122 | }, 123 | // Add more execution handlers for other tools that require confirmation 124 | }; 125 | ``` 126 | 127 | Tools can be configured in two ways: 128 | 129 | 1. With an `execute` function for automatic execution 130 | 2. Without an `execute` function, requiring confirmation and using the `executions` object to handle the confirmed action 131 | 132 | ### Use a different AI model provider 133 | 134 | 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: 135 | 136 | 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: 137 | 2. Replacing the AI SDK with the [OpenAI SDK](https://github.com/openai/openai-node) 138 | 3. Using the Cloudflare [Workers AI + AI Gateway](https://developers.cloudflare.com/ai-gateway/providers/workersai/#workers-binding) binding API directly 139 | 140 | For example, to use the [`workers-ai-provider`](https://sdk.vercel.ai/providers/community-providers/cloudflare-workers-ai), install the package: 141 | 142 | ```sh 143 | npm install workers-ai-provider 144 | ``` 145 | 146 | Add an `ai` binding to `wrangler.jsonc`: 147 | 148 | ```jsonc 149 | // rest of file 150 | "ai": { 151 | "binding": "AI" 152 | } 153 | // rest of file 154 | ``` 155 | 156 | Replace the `@ai-sdk/openai` import and usage with the `workers-ai-provider`: 157 | 158 | ```diff 159 | // server.ts 160 | // Change the imports 161 | - import { openai } from "@ai-sdk/openai"; 162 | + import { createWorkersAI } from 'workers-ai-provider'; 163 | 164 | // Create a Workers AI instance 165 | + const workersai = createWorkersAI({ binding: env.AI }); 166 | 167 | // Use it when calling the streamText method (or other methods) 168 | // from the ai-sdk 169 | - const model = openai("gpt-4o-2024-11-20"); 170 | + const model = workersai("@cf/deepseek-ai/deepseek-r1-distill-qwen-32b") 171 | ``` 172 | 173 | Commit your changes and then run the `agents-starter` as per the rest of this README. 174 | 175 | ### Modifying the UI 176 | 177 | The chat interface is built with React and can be customized in `app.tsx`: 178 | 179 | - Modify the theme colors in `styles.css` 180 | - Add new UI components in the chat container 181 | - Customize message rendering and tool confirmation dialogs 182 | - Add new controls to the header 183 | 184 | ### Example Use Cases 185 | 186 | 1. **Customer Support Agent** 187 | 188 | - Add tools for: 189 | - Ticket creation/lookup 190 | - Order status checking 191 | - Product recommendations 192 | - FAQ database search 193 | 194 | 2. **Development Assistant** 195 | 196 | - Integrate tools for: 197 | - Code linting 198 | - Git operations 199 | - Documentation search 200 | - Dependency checking 201 | 202 | 3. **Data Analysis Assistant** 203 | 204 | - Build tools for: 205 | - Database querying 206 | - Data visualization 207 | - Statistical analysis 208 | - Report generation 209 | 210 | 4. **Personal Productivity Assistant** 211 | 212 | - Implement tools for: 213 | - Task scheduling with flexible timing options 214 | - One-time, delayed, and recurring task management 215 | - Task tracking with reminders 216 | - Email drafting 217 | - Note taking 218 | 219 | 5. **Scheduling Assistant** 220 | - Build tools for: 221 | - One-time event scheduling using specific dates 222 | - Delayed task execution (e.g., "remind me in 30 minutes") 223 | - Recurring tasks using cron patterns 224 | - Task payload management 225 | - Flexible scheduling patterns 226 | 227 | Each use case can be implemented by: 228 | 229 | 1. Adding relevant tools in `tools.ts` 230 | 2. Customizing the UI for specific interactions 231 | 3. Extending the agent's capabilities in `server.ts` 232 | 4. Adding any necessary external API integrations 233 | 234 | ## Learn More 235 | 236 | - [`agents`](https://github.com/cloudflare/agents/blob/main/packages/agents/README.md) 237 | - [Cloudflare Agents Documentation](https://developers.cloudflare.com/agents/) 238 | - [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/) 239 | 240 | ## License 241 | 242 | MIT 243 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["node_modules", "dist", "./worker-configuration.d.ts"] 11 | }, 12 | "formatter": { 13 | "enabled": false, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "style": { 24 | "noNonNullAssertion": "off" 25 | } 26 | } 27 | }, 28 | "javascript": { 29 | "formatter": { 30 | "quoteStyle": "double" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/styles.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "phosphor" 21 | } 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | AI Chat Agent 13 | 14 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 41 | 42 | 43 | 44 |
45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-agent-starter", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "private": true, 7 | "scripts": { 8 | "start": "vite dev", 9 | "deploy": "vite build && wrangler deploy", 10 | "test": "vitest", 11 | "types": "wrangler types", 12 | "format": "prettier --write .", 13 | "check": "prettier . --check && biome lint && tsc" 14 | }, 15 | "keywords": [ 16 | "cloudflare", 17 | "ai", 18 | "agents" 19 | ], 20 | "author": "", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "@biomejs/biome": "^1.9.4", 24 | "@cloudflare/vite-plugin": "1.0.13", 25 | "@cloudflare/vitest-pool-workers": "^0.8.23", 26 | "@cloudflare/workers-types": "^4.20250430.0", 27 | "@tailwindcss/vite": "^4.1.5", 28 | "@types/node": "^22.15.3", 29 | "@types/react": "^19.1.2", 30 | "@types/react-dom": "^19.1.3", 31 | "@vitejs/plugin-react": "^4.4.1", 32 | "prettier": "^3.5.3", 33 | "tailwindcss": "^4.1.5", 34 | "typescript": "^5.8.3", 35 | "vite": "^6.3.4", 36 | "vitest": "3.1.2", 37 | "wrangler": "^4.13.2" 38 | }, 39 | "dependencies": { 40 | "@ai-sdk/openai": "^1.3.21", 41 | "@ai-sdk/react": "^1.2.11", 42 | "@ai-sdk/ui-utils": "^1.2.10", 43 | "@phosphor-icons/react": "^2.1.7", 44 | "@radix-ui/react-avatar": "^1.1.7", 45 | "@radix-ui/react-dropdown-menu": "^2.1.12", 46 | "@radix-ui/react-slot": "^1.2.0", 47 | "@radix-ui/react-switch": "^1.2.2", 48 | "@types/marked": "^6.0.0", 49 | "agents": "^0.0.76", 50 | "ai": "^4.3.13", 51 | "class-variance-authority": "^0.7.1", 52 | "clsx": "^2.1.1", 53 | "marked": "^15.0.11", 54 | "react": "^19.1.0", 55 | "react-dom": "^19.1.0", 56 | "react-markdown": "^10.1.0", 57 | "remark-gfm": "^4.0.1", 58 | "tailwind-merge": "^3.2.0", 59 | "zod": "^3.24.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/agents-starter/8ef957ba34c94940c7fedf9fe14be9395fca111e/public/favicon.ico -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef, useCallback, use } from "react"; 2 | import { useAgent } from "agents/react"; 3 | import { useAgentChat } from "agents/ai-react"; 4 | import type { Message } from "@ai-sdk/react"; 5 | import type { tools } from "./tools"; 6 | 7 | // Component imports 8 | import { Button } from "@/components/button/Button"; 9 | import { Card } from "@/components/card/Card"; 10 | import { Input } from "@/components/input/Input"; 11 | import { Avatar } from "@/components/avatar/Avatar"; 12 | import { Toggle } from "@/components/toggle/Toggle"; 13 | import { MemoizedMarkdown } from "./components/memoized-markdown"; 14 | import { ToolInvocationCard } from "@/components/tool-invocation-card/ToolInvocationCard"; 15 | 16 | // Icon imports 17 | import { 18 | Bug, 19 | Moon, 20 | PaperPlaneRight, 21 | Robot, 22 | Sun, 23 | Trash, 24 | } from "@phosphor-icons/react"; 25 | 26 | // List of tools that require human confirmation 27 | const toolsRequiringConfirmation: (keyof typeof tools)[] = [ 28 | "getWeatherInformation", 29 | ]; 30 | 31 | export default function Chat() { 32 | const [theme, setTheme] = useState<"dark" | "light">(() => { 33 | // Check localStorage first, default to dark if not found 34 | const savedTheme = localStorage.getItem("theme"); 35 | return (savedTheme as "dark" | "light") || "dark"; 36 | }); 37 | const [showDebug, setShowDebug] = useState(false); 38 | const messagesEndRef = useRef(null); 39 | 40 | const scrollToBottom = useCallback(() => { 41 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 42 | }, []); 43 | 44 | useEffect(() => { 45 | // Apply theme class on mount and when theme changes 46 | if (theme === "dark") { 47 | document.documentElement.classList.add("dark"); 48 | document.documentElement.classList.remove("light"); 49 | } else { 50 | document.documentElement.classList.remove("dark"); 51 | document.documentElement.classList.add("light"); 52 | } 53 | 54 | // Save theme preference to localStorage 55 | localStorage.setItem("theme", theme); 56 | }, [theme]); 57 | 58 | // Scroll to bottom on mount 59 | useEffect(() => { 60 | scrollToBottom(); 61 | }, [scrollToBottom]); 62 | 63 | const toggleTheme = () => { 64 | const newTheme = theme === "dark" ? "light" : "dark"; 65 | setTheme(newTheme); 66 | }; 67 | 68 | const agent = useAgent({ 69 | agent: "chat", 70 | }); 71 | 72 | const { 73 | messages: agentMessages, 74 | input: agentInput, 75 | handleInputChange: handleAgentInputChange, 76 | handleSubmit: handleAgentSubmit, 77 | addToolResult, 78 | clearHistory, 79 | } = useAgentChat({ 80 | agent, 81 | maxSteps: 5, 82 | }); 83 | 84 | // Scroll to bottom when messages change 85 | useEffect(() => { 86 | agentMessages.length > 0 && scrollToBottom(); 87 | }, [agentMessages, scrollToBottom]); 88 | 89 | const pendingToolCallConfirmation = agentMessages.some((m: Message) => 90 | m.parts?.some( 91 | (part) => 92 | part.type === "tool-invocation" && 93 | part.toolInvocation.state === "call" && 94 | toolsRequiringConfirmation.includes( 95 | part.toolInvocation.toolName as keyof typeof tools 96 | ) 97 | ) 98 | ); 99 | 100 | const formatTime = (date: Date) => { 101 | return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 102 | }; 103 | 104 | return ( 105 |
106 | 107 |
108 |
109 |
110 | 116 | Cloudflare Agents 117 | 118 | 122 | 123 | 124 | 125 |
126 | 127 |
128 |

AI Chat Agent

129 |
130 | 131 |
132 | 133 | setShowDebug((prev) => !prev)} 137 | /> 138 |
139 | 140 | 149 | 150 | 159 |
160 | 161 | {/* Messages */} 162 |
163 | {agentMessages.length === 0 && ( 164 |
165 | 166 |
167 |
168 | 169 |
170 |

Welcome to AI Chat

171 |

172 | Start a conversation with your AI assistant. Try asking 173 | about: 174 |

175 |
    176 |
  • 177 | 178 | Weather information for any city 179 |
  • 180 |
  • 181 | 182 | Local time in different locations 183 |
  • 184 |
185 |
186 |
187 |
188 | )} 189 | 190 | {agentMessages.map((m: Message, index) => { 191 | const isUser = m.role === "user"; 192 | const showAvatar = 193 | index === 0 || agentMessages[index - 1]?.role !== m.role; 194 | 195 | return ( 196 |
197 | {showDebug && ( 198 |
199 |                     {JSON.stringify(m, null, 2)}
200 |                   
201 | )} 202 |
205 |
210 | {showAvatar && !isUser ? ( 211 | 212 | ) : ( 213 | !isUser &&
214 | )} 215 | 216 |
217 |
218 | {m.parts?.map((part, i) => { 219 | if (part.type === "text") { 220 | return ( 221 | // biome-ignore lint/suspicious/noArrayIndexKey: immutable index 222 |
223 | 234 | {part.text.startsWith( 235 | "scheduled message" 236 | ) && ( 237 | 238 | 🕒 239 | 240 | )} 241 | 248 | 249 |

254 | {formatTime( 255 | new Date(m.createdAt as unknown as string) 256 | )} 257 |

258 |
259 | ); 260 | } 261 | 262 | if (part.type === "tool-invocation") { 263 | const toolInvocation = part.toolInvocation; 264 | const toolCallId = toolInvocation.toolCallId; 265 | const needsConfirmation = 266 | toolsRequiringConfirmation.includes( 267 | toolInvocation.toolName as keyof typeof tools 268 | ); 269 | 270 | // Skip rendering the card in debug mode 271 | if (showDebug) return null; 272 | 273 | return ( 274 | 282 | ); 283 | } 284 | return null; 285 | })} 286 |
287 |
288 |
289 |
290 |
291 | ); 292 | })} 293 |
294 |
295 | 296 | {/* Input Area */} 297 |
299 | handleAgentSubmit(e, { 300 | data: { 301 | annotations: { 302 | hello: "world", 303 | }, 304 | }, 305 | }) 306 | } 307 | className="p-3 bg-input-background absolute bottom-0 left-0 right-0 z-10 border-t border-neutral-300 dark:border-neutral-800" 308 | > 309 |
310 |
311 | { 322 | if ( 323 | e.key === "Enter" && 324 | !e.shiftKey && 325 | !e.nativeEvent.isComposing 326 | ) { 327 | e.preventDefault(); 328 | handleAgentSubmit(e as unknown as React.FormEvent); 329 | } 330 | }} 331 | onValueChange={undefined} 332 | /> 333 |
334 | 335 | 343 |
344 |
345 |
346 |
347 | ); 348 | } 349 | 350 | const hasOpenAiKeyPromise = fetch("/check-open-ai-key").then((res) => 351 | res.json<{ success: boolean }>() 352 | ); 353 | 354 | function HasOpenAIKey() { 355 | const hasOpenAiKey = use(hasOpenAiKeyPromise); 356 | 357 | if (!hasOpenAiKey.success) { 358 | return ( 359 |
360 |
361 |
362 |
363 |
364 | 375 | Warning Icon 376 | 377 | 378 | 379 | 380 |
381 |
382 |

383 | OpenAI API Key Not Configured 384 |

385 |

386 | Requests to the API, including from the frontend UI, will not 387 | work until an OpenAI API key is configured. 388 |

389 |

390 | Please configure an OpenAI API key by setting a{" "} 391 | 397 | secret 398 | {" "} 399 | named{" "} 400 | 401 | OPENAI_API_KEY 402 | 403 | .
404 | You can also use a different model provider by following these{" "} 405 | 411 | instructions. 412 | 413 |

414 |
415 |
416 |
417 |
418 |
419 | ); 420 | } 421 | return null; 422 | } 423 | -------------------------------------------------------------------------------- /src/client.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./app"; 4 | import { Providers } from "@/providers"; 5 | 6 | const root = createRoot(document.getElementById("app")!); 7 | 8 | root.render( 9 | 10 |
11 | 12 |
13 |
14 | ); 15 | -------------------------------------------------------------------------------- /src/components/avatar/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@/components/slot/Slot"; 2 | import { Tooltip } from "@/components/tooltip/Tooltip"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | export type AvatarProps = { 6 | as?: React.ElementType; 7 | className?: string; 8 | external?: boolean; 9 | href?: string; 10 | id?: number | string; 11 | image?: string; 12 | size?: "sm" | "md" | "base"; 13 | toggled?: boolean; 14 | tooltip?: string; 15 | username: string; 16 | }; 17 | 18 | const AvatarComponent = ({ 19 | as, 20 | className, 21 | external, 22 | href, 23 | image, 24 | size = "base", 25 | toggled, 26 | username, 27 | }: AvatarProps) => { 28 | const firstInitial = username.charAt(0).toUpperCase(); 29 | 30 | return ( 31 | 51 | {image ? ( 52 | {username} 59 | ) : ( 60 |

{firstInitial}

61 | )} 62 |
63 | ); 64 | }; 65 | 66 | export const Avatar = ({ ...props }: AvatarProps) => { 67 | return props.tooltip ? ( 68 | 69 | 70 | 71 | ) : ( 72 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /src/components/button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "@/components/loader/Loader"; 2 | import { Slot } from "@/components/slot/Slot"; 3 | import { Tooltip } from "@/components/tooltip/Tooltip"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export type ButtonProps = React.ButtonHTMLAttributes & { 7 | as?: React.ElementType; 8 | children?: React.ReactNode; 9 | className?: string; 10 | displayContent?: "items-first" | "items-last"; // used for children of component 11 | external?: boolean; 12 | href?: string; 13 | loading?: boolean; 14 | shape?: "base" | "square" | "circular"; 15 | size?: "sm" | "md" | "lg" | "base"; 16 | title?: string | React.ReactNode; 17 | toggled?: boolean; 18 | tooltip?: string; 19 | variant?: "primary" | "secondary" | "ghost" | "destructive" | "tertiary"; 20 | }; 21 | 22 | const ButtonComponent = ({ 23 | as, 24 | children, 25 | className, 26 | disabled, 27 | displayContent = "items-last", 28 | external, 29 | href, 30 | loading, 31 | shape = "base", 32 | size = "base", 33 | title, 34 | toggled, 35 | variant = "secondary", 36 | ...props 37 | }: ButtonProps) => { 38 | return ( 39 | 72 | {title} 73 | 74 | {loading ? ( 75 | 84 | 85 | 86 | ) : ( 87 | children 88 | )} 89 | 90 | ); 91 | }; 92 | 93 | export const Button = ({ ...props }: ButtonProps) => { 94 | return props.tooltip ? ( 95 | 96 | 97 | 98 | ) : ( 99 | 100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /src/components/button/RefreshButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/button/Button"; 2 | import type { ButtonProps } from "@/components/button/Button"; 3 | import { cn } from "@/lib/utils"; 4 | import { ArrowsClockwise } from "@phosphor-icons/react"; 5 | 6 | export const RefreshButton = ({ ...props }: ButtonProps) => ( 7 | 17 | ); 18 | -------------------------------------------------------------------------------- /src/components/card/Card.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | type CardProps = { 4 | as?: React.ElementType; 5 | children?: React.ReactNode; 6 | className?: string; 7 | ref?: React.Ref; 8 | tabIndex?: number; 9 | variant?: "primary" | "secondary" | "ghost" | "destructive"; 10 | }; 11 | 12 | export const Card = ({ 13 | as, 14 | children, 15 | className, 16 | ref, 17 | tabIndex, 18 | variant = "secondary", 19 | }: CardProps) => { 20 | const Component = as ?? "div"; 21 | return ( 22 | 34 | {children} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/dropdown/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import { DotsThree, IconContext } from "@phosphor-icons/react"; 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { cva } from "class-variance-authority"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", 16 | outline: 17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ); 36 | 37 | export type MenuItemProps = { 38 | destructiveAction?: boolean; 39 | href?: string; 40 | hrefExternal?: boolean; 41 | icon?: React.ReactNode; 42 | label?: string | React.ReactNode; 43 | checked?: boolean; 44 | onClick?: (event: React.MouseEvent) => void; 45 | titleContent?: React.ReactNode; 46 | type: "button" | "link" | "divider" | "title" | "checkbox" | string; 47 | }; 48 | 49 | export type DropdownMenuProps = { 50 | align: "center" | "end" | "start"; 51 | alignOffset?: number; 52 | buttonProps?: React.ComponentProps; 53 | children?: React.ReactNode; 54 | className?: string; 55 | disabled?: boolean; 56 | MenuItems: Array | null; 57 | onCloseRmFocus?: boolean; 58 | side: "bottom" | "left" | "right" | "top"; 59 | sideOffset?: number; 60 | size?: "sm" | "base"; 61 | id?: string; 62 | }; 63 | 64 | const DropdownMenu = ({ 65 | align, 66 | alignOffset, 67 | buttonProps, 68 | children, 69 | className, 70 | disabled, 71 | MenuItems, 72 | onCloseRmFocus = true, 73 | side, 74 | sideOffset, 75 | id, 76 | size = "base", 77 | }: DropdownMenuProps) => ( 78 | 79 | 98 | {children ?? } 99 | 100 | 101 | { 107 | onCloseRmFocus ? e.preventDefault() : null; 108 | }} 109 | className={cn( 110 | "z-modal radix-state-closed:animate-scaleFadeOutSm radix-state-open:animate-scaleFadeInSm overflow-hidden rounded-xl border border-neutral-200 bg-white p-1.5 py-1.5 text-base font-medium text-neutral-900 shadow-lg shadow-black/5 transition-transform duration-100 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white", 111 | { 112 | "origin-top-right": align === "end" && side === "bottom", 113 | "origin-top-left": align === "start" && side === "bottom", 114 | "origin-bottom-right": align === "end" && side === "top", 115 | "origin-bottom-left": align === "start" && side === "top", 116 | "text-sm font-normal": size === "sm", 117 | } 118 | )} 119 | > 120 | {MenuItems?.map((item, index) => { 121 | if (item.type === "title") { 122 | return ( 123 |
e.preventDefault()} 126 | onKeyDown={(e) => e.preventDefault()} 127 | // biome-ignore lint/suspicious/noArrayIndexKey: TODO 128 | key={index} 129 | > 130 | {item.titleContent} 131 |
132 | ); 133 | } 134 | if (item.type === "divider") { 135 | return ( 136 | // biome-ignore lint/suspicious/noArrayIndexKey: TODO 137 |
138 |
139 |
140 | ); 141 | } 142 | if (item.type === "link" || item.type === "button") { 143 | return ( 144 | // biome-ignore lint/suspicious/noArrayIndexKey: TODO 145 | 146 | {item.type === "link" ? ( 147 | 152 | {item.label} 153 | 158 | {item.icon} 159 | 160 | 161 | ) : ( 162 | 182 | )} 183 | 184 | ); 185 | } 186 | })} 187 | 188 | 189 | 190 | ); 191 | 192 | DropdownMenu.displayName = "DropdownMenu"; 193 | 194 | export { DropdownMenu }; 195 | -------------------------------------------------------------------------------- /src/components/input/Input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { useMemo, useRef, useState } from "react"; 3 | 4 | export const inputClasses = cn( 5 | "bg-ob-btn-secondary-bg text-ob-base-300 border-ob-border focus:border-ob-border-active placeholder:text-ob-base-100 add-disable border border-1 transition-colors focus:outline-none" 6 | ); 7 | 8 | export type InputProps = Omit< 9 | React.InputHTMLAttributes, 10 | "size" 11 | > & { 12 | children?: React.ReactNode; 13 | className?: string; 14 | displayContent?: "items-first" | "items-last"; // used for children of component 15 | initialValue?: string; 16 | isValid?: boolean; 17 | onValueChange: ((value: string, isValid: boolean) => void) | undefined; 18 | preText?: string[] | React.ReactNode[] | React.ReactNode; 19 | postText?: string[] | React.ReactNode[] | React.ReactNode; 20 | size?: "sm" | "md" | "base"; 21 | }; 22 | 23 | export const Input = ({ 24 | children, 25 | className, 26 | displayContent, 27 | initialValue, 28 | isValid = true, 29 | onValueChange, 30 | preText, 31 | postText, 32 | size = "base", 33 | ...props 34 | }: InputProps) => { 35 | const [currentValue, setCurrentValue] = useState(initialValue ?? ""); 36 | const inputRef = useRef(null); 37 | 38 | useMemo(() => { 39 | setCurrentValue(initialValue ?? ""); 40 | }, [initialValue]); 41 | 42 | const updateCurrentValue = (event: React.ChangeEvent) => { 43 | const newValue = event.target.value; 44 | setCurrentValue(newValue); 45 | 46 | if (onValueChange) { 47 | if (!props.min) { 48 | onValueChange(newValue, isValid); 49 | } else if (typeof props.min === "number") { 50 | onValueChange(newValue.slice(0, props.min), isValid); 51 | } 52 | } 53 | }; 54 | 55 | const handlePreTextInputClick = () => { 56 | if (inputRef.current) { 57 | inputRef.current.focus(); 58 | } 59 | }; 60 | 61 | return preText ? ( 62 | // biome-ignore lint/a11y/useKeyWithClickEvents: todo 63 |
76 | 77 | {preText} 78 | 79 | 80 | 92 | 93 | 94 | {postText} 95 | 96 |
97 | ) : ( 98 | 113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /src/components/label/Label.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | export type LabelProps = React.LabelHTMLAttributes & { 4 | children?: React.ReactNode; 5 | className?: string; 6 | isValid?: boolean; 7 | title: string; 8 | required?: boolean; 9 | requiredDescription?: string; 10 | }; 11 | 12 | export const Label = ({ 13 | children, 14 | className, 15 | isValid, 16 | title, 17 | htmlFor, 18 | required, 19 | requiredDescription, 20 | ...props 21 | }: LabelProps) => { 22 | return ( 23 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | type LoaderProps = { 2 | className?: string; 3 | size?: number; 4 | title?: string; 5 | }; 6 | 7 | export const Loader = ({ 8 | className, 9 | size = 24, 10 | title = "Loading...", 11 | }: LoaderProps) => ( 12 | 21 | {title} 22 | 30 | 38 | 39 | 46 | 47 | 54 | 55 | 64 | 65 | ); 66 | -------------------------------------------------------------------------------- /src/components/memoized-markdown.tsx: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | import type { Tokens } from "marked"; 3 | import { memo, useMemo } from "react"; 4 | import ReactMarkdown from "react-markdown"; 5 | import remarkGfm from "remark-gfm"; 6 | 7 | function parseMarkdownIntoBlocks(markdown: string): string[] { 8 | const tokens: TokensList = marked.lexer(markdown); 9 | return tokens.map((token: Tokens.Generic) => token.raw); 10 | } 11 | 12 | type TokensList = Array; 13 | 14 | const MemoizedMarkdownBlock = memo( 15 | ({ content }: { content: string }) => ( 16 |
17 | {content} 18 |
19 | ), 20 | (prevProps, nextProps) => prevProps.content === nextProps.content 21 | ); 22 | 23 | MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock"; 24 | 25 | export const MemoizedMarkdown = memo( 26 | ({ content, id }: { content: string; id: string }) => { 27 | const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]); 28 | return blocks.map((block, index) => ( 29 | // biome-ignore lint/suspicious/noArrayIndexKey: immutable index 30 | 31 | )); 32 | } 33 | ); 34 | 35 | MemoizedMarkdown.displayName = "MemoizedMarkdown"; 36 | -------------------------------------------------------------------------------- /src/components/menu-bar/MenuBar.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from "@/components/tooltip/Tooltip"; 2 | import { useMenuNavigation } from "@/hooks/useMenuNavigation"; 3 | import { cn } from "@/lib/utils"; 4 | import { IconContext } from "@phosphor-icons/react"; 5 | import { useRef } from "react"; 6 | 7 | type MenuOptionProps = { 8 | icon: React.ReactNode; 9 | id?: number; 10 | isActive?: number | boolean | string | undefined; 11 | onClick: () => void; 12 | tooltip: string; 13 | }; 14 | 15 | const MenuOption = ({ 16 | icon, 17 | id, 18 | isActive, 19 | onClick, 20 | tooltip, 21 | }: MenuOptionProps) => ( 22 | 27 | 40 | 41 | ); 42 | 43 | type MenuBarProps = { 44 | className?: string; 45 | isActive: number | boolean | string | undefined; 46 | options: MenuOptionProps[]; 47 | optionIds?: boolean; 48 | }; 49 | 50 | export const MenuBar = ({ 51 | className, 52 | isActive, 53 | options, 54 | optionIds = false, // if option needs an extra unique ID 55 | }: MenuBarProps) => { 56 | const menuRef = useRef(null); 57 | 58 | useMenuNavigation({ menuRef, direction: "horizontal" }); 59 | 60 | return ( 61 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/button/Button"; 2 | import { Card } from "@/components/card/Card"; 3 | import useClickOutside from "@/hooks/useClickOutside"; 4 | import { X } from "@phosphor-icons/react"; 5 | 6 | import { useEffect, useRef } from "react"; 7 | import { cn } from "@/lib/utils"; 8 | 9 | type ModalProps = { 10 | className?: string; 11 | children: React.ReactNode; 12 | clickOutsideToClose?: boolean; 13 | isOpen: boolean; 14 | onClose: () => void; 15 | }; 16 | 17 | export const Modal = ({ 18 | className, 19 | children, 20 | clickOutsideToClose = false, 21 | isOpen, 22 | onClose, 23 | }: ModalProps) => { 24 | const modalRef = clickOutsideToClose 25 | ? useClickOutside(onClose) 26 | : useRef(null); 27 | 28 | // Stop site overflow when modal is open 29 | useEffect(() => { 30 | if (isOpen) { 31 | document.body.style.overflow = "hidden"; 32 | } else { 33 | document.body.style.overflow = ""; 34 | } 35 | 36 | return () => { 37 | document.body.style.overflow = ""; 38 | }; 39 | }, [isOpen]); 40 | 41 | // Tab focus 42 | useEffect(() => { 43 | if (!isOpen || !modalRef.current) return; 44 | 45 | const focusableElements = modalRef.current.querySelectorAll( 46 | 'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])' 47 | ) as NodeListOf; 48 | 49 | const firstElement = focusableElements[0]; 50 | const lastElement = focusableElements[focusableElements.length - 1]; 51 | 52 | if (firstElement) firstElement.focus(); 53 | 54 | const handleKeyDown = (e: KeyboardEvent) => { 55 | if (e.key === "Tab") { 56 | if (e.shiftKey) { 57 | // Shift + Tab moves focus backward 58 | if (document.activeElement === firstElement) { 59 | e.preventDefault(); 60 | lastElement.focus(); 61 | } 62 | } else { 63 | // Tab moves focus forward 64 | if (document.activeElement === lastElement) { 65 | e.preventDefault(); 66 | firstElement.focus(); 67 | } 68 | } 69 | } 70 | if (e.key === "Escape") { 71 | onClose(); 72 | } 73 | }; 74 | 75 | document.addEventListener("keydown", handleKeyDown); 76 | return () => { 77 | document.removeEventListener("keydown", handleKeyDown); 78 | }; 79 | }, [isOpen, onClose, modalRef.current]); 80 | 81 | if (!isOpen) return null; 82 | 83 | return ( 84 |
85 |
86 | 87 | 92 | {children} 93 | 94 | 103 | 104 |
105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /src/components/orbit-site/Block.tsx: -------------------------------------------------------------------------------- 1 | type BlockProps = { 2 | children: React.ReactNode; 3 | title?: string; 4 | }; 5 | 6 | const Block = ({ children, title }: BlockProps) => { 7 | return ( 8 | <> 9 | {title && ( 10 |
11 |

12 | {title} 13 |

14 |
15 | )} 16 |
17 | {children} 18 |
19 | 20 | ); 21 | }; 22 | 23 | export default Block; 24 | -------------------------------------------------------------------------------- /src/components/orbit-site/Inset.tsx: -------------------------------------------------------------------------------- 1 | type InsetProps = { 2 | children?: React.ReactNode; 3 | }; 4 | 5 | const Inset = ({ children }: InsetProps) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | }; 12 | 13 | export default Inset; 14 | -------------------------------------------------------------------------------- /src/components/orbit-site/Section.tsx: -------------------------------------------------------------------------------- 1 | type SectionProps = { 2 | children?: React.ReactNode; 3 | }; 4 | 5 | const Section = ({ children }: SectionProps) => { 6 | return
{children}
; 7 | }; 8 | 9 | export default Section; 10 | -------------------------------------------------------------------------------- /src/components/orbit-site/ThemeSelector.tsx: -------------------------------------------------------------------------------- 1 | import useTheme from "@/hooks/useTheme"; 2 | import { cn } from "@/lib/utils"; 3 | import { Moon, Sun } from "@phosphor-icons/react"; 4 | import { useState } from "react"; 5 | 6 | const ThemeSelector = () => { 7 | const [theme, setTheme] = useState<"dark" | "light">("light"); 8 | 9 | useTheme(theme); 10 | 11 | const toggleTheme = () => { 12 | setTheme((prevTheme) => (prevTheme === "dark" ? "light" : "dark")); 13 | }; 14 | 15 | return ( 16 | 34 | ); 35 | }; 36 | 37 | export default ThemeSelector; 38 | -------------------------------------------------------------------------------- /src/components/select/Select.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { useState } from "react"; 3 | 4 | export type OptionProps = { 5 | value: string; 6 | }; 7 | 8 | export type SelectProps = { 9 | className?: string; 10 | options: OptionProps[]; 11 | placeholder?: string; 12 | setValue: (value: string) => void; 13 | size?: "sm" | "md" | "base"; 14 | value: string; 15 | }; 16 | 17 | export const Select = ({ 18 | className, 19 | options, 20 | placeholder, 21 | setValue, 22 | size = "base", 23 | value, 24 | }: SelectProps) => { 25 | const [isPointer, setIsPointer] = useState(false); // if user is using a pointer device 26 | 27 | return ( 28 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/slot/Slot.tsx: -------------------------------------------------------------------------------- 1 | type SlotProps = { 2 | as: T; 3 | } & React.ComponentPropsWithRef; 4 | 5 | export const Slot = ({ 6 | as, 7 | children, 8 | ...props 9 | }: SlotProps) => { 10 | const Component = as; 11 | return {children}; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/toggle/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | type ToggleProps = { 4 | onClick: () => void; 5 | size?: "sm" | "base" | "lg"; 6 | toggled: boolean; 7 | }; 8 | 9 | export const Toggle = ({ onClick, size = "base", toggled }: ToggleProps) => { 10 | return ( 11 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/tool-invocation-card/ToolInvocationCard.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Robot, CaretDown } from "@phosphor-icons/react"; 3 | import { Button } from "@/components/button/Button"; 4 | import { Card } from "@/components/card/Card"; 5 | import { Tooltip } from "@/components/tooltip/Tooltip"; 6 | import { APPROVAL } from "@/shared"; 7 | 8 | interface ToolInvocation { 9 | toolName: string; 10 | toolCallId: string; 11 | state: "call" | "result" | "partial-call"; 12 | step?: number; 13 | args: Record; 14 | result?: { 15 | content?: Array<{ type: string; text: string }>; 16 | }; 17 | } 18 | 19 | interface ToolInvocationCardProps { 20 | toolInvocation: ToolInvocation; 21 | toolCallId: string; 22 | needsConfirmation: boolean; 23 | addToolResult: (args: { toolCallId: string; result: string }) => void; 24 | } 25 | 26 | export function ToolInvocationCard({ 27 | toolInvocation, 28 | toolCallId, 29 | needsConfirmation, 30 | addToolResult, 31 | }: ToolInvocationCardProps) { 32 | const [isExpanded, setIsExpanded] = useState(true); 33 | 34 | return ( 35 | 40 | 61 | 62 |
65 |
69 |
70 |
71 | Arguments: 72 |
73 |
 74 |               {JSON.stringify(toolInvocation.args, null, 2)}
 75 |             
76 |
77 | 78 | {needsConfirmation && toolInvocation.state === "call" && ( 79 |
80 | 92 | 93 | 105 | 106 |
107 | )} 108 | 109 | {!needsConfirmation && toolInvocation.state === "result" && ( 110 |
111 |
112 | Result: 113 |
114 |
115 |                 {(() => {
116 |                   const result = toolInvocation.result;
117 |                   if (typeof result === "object" && result.content) {
118 |                     return result.content
119 |                       .map((item: { type: string; text: string }) => {
120 |                         if (
121 |                           item.type === "text" &&
122 |                           item.text.startsWith("\n~ Page URL:")
123 |                         ) {
124 |                           const lines = item.text.split("\n").filter(Boolean);
125 |                           return lines
126 |                             .map(
127 |                               (line: string) => `- ${line.replace("\n~ ", "")}`
128 |                             )
129 |                             .join("\n");
130 |                         }
131 |                         return item.text;
132 |                       })
133 |                       .join("\n");
134 |                   }
135 |                   return JSON.stringify(result, null, 2);
136 |                 })()}
137 |               
138 |
139 | )} 140 |
141 |
142 |
143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /src/components/tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { useTooltip } from "@/providers/TooltipProvider"; 2 | import { cn } from "@/lib/utils"; 3 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; 4 | 5 | export type TooltipProps = { 6 | children: React.ReactNode; 7 | className?: string; 8 | content: string; 9 | id?: number | string; 10 | }; 11 | 12 | export const Tooltip = ({ children, className, content, id }: TooltipProps) => { 13 | const { activeTooltip, showTooltip, hideTooltip } = useTooltip(); 14 | const [positionX, setPositionX] = useState<"center" | "left" | "right">( 15 | "center" 16 | ); 17 | const [positionY, setPositionY] = useState<"top" | "bottom">("top"); 18 | const [isHoverAvailable, setIsHoverAvailable] = useState(false); // if hover state exists 19 | const [isPointer, setIsPointer] = useState(false); // if user is using a pointer device 20 | 21 | const tooltipRef = useRef(null); 22 | 23 | useEffect(() => { 24 | setIsHoverAvailable(window.matchMedia("(hover: hover)").matches); // check if hover state is available 25 | }, []); 26 | 27 | const tooltipIdentifier = id ? id + content : content; 28 | const tooltipId = `tooltip-${id || content.replace(/\s+/g, "-")}`; // used for ARIA 29 | 30 | const isVisible = activeTooltip === tooltipIdentifier; 31 | 32 | // detect collision once the tooltip is visible 33 | useLayoutEffect(() => { 34 | const detectCollision = () => { 35 | const ref = tooltipRef.current; 36 | 37 | if (ref) { 38 | const tooltipRect = ref.getBoundingClientRect(); 39 | const { top, left, bottom, right } = tooltipRect; 40 | const viewportWidth = window.innerWidth; 41 | const viewportHeight = window.innerHeight; 42 | 43 | if (top <= 0) setPositionY("bottom"); 44 | if (left <= 0) setPositionX("left"); 45 | if (bottom >= viewportHeight) setPositionY("top"); 46 | if (right >= viewportWidth) setPositionX("right"); 47 | } 48 | }; 49 | 50 | if (!isVisible) { 51 | setPositionX("center"); 52 | setPositionY("top"); 53 | } else { 54 | detectCollision(); 55 | } 56 | }, [isVisible]); 57 | 58 | return ( 59 |
63 | isHoverAvailable && showTooltip(tooltipIdentifier, false) 64 | } 65 | onMouseLeave={() => hideTooltip()} 66 | onPointerDown={(e: React.PointerEvent) => { 67 | if (e.pointerType === "mouse") { 68 | setIsPointer(true); 69 | } 70 | }} 71 | onPointerUp={() => setIsPointer(false)} 72 | onFocus={() => { 73 | // only allow tooltips when hover state is available 74 | if (isHoverAvailable) { 75 | isPointer // if user clicks with a mouse, do not auto-populate tooltip 76 | ? showTooltip(tooltipIdentifier, false) 77 | : showTooltip(tooltipIdentifier, true); 78 | } else { 79 | hideTooltip(); 80 | } 81 | }} 82 | onBlur={() => hideTooltip()} 83 | > 84 | {children} 85 | {isVisible && ( 86 | 102 | {content} 103 | 104 | )} 105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | const useClickOutside = (callback: () => void) => { 4 | const ref = useRef(null); 5 | 6 | useEffect(() => { 7 | const handleClick = (event: MouseEvent) => { 8 | if (ref.current && !ref.current.contains(event.target as Node)) { 9 | callback(); 10 | } 11 | }; 12 | 13 | document.addEventListener("click", handleClick); 14 | 15 | return () => { 16 | document.removeEventListener("click", handleClick); 17 | }; 18 | }, [callback]); 19 | 20 | return ref; 21 | }; 22 | 23 | export default useClickOutside; 24 | -------------------------------------------------------------------------------- /src/hooks/useMenuNavigation.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | type UseMenuNavigationProps = { 4 | menuRef: React.RefObject; 5 | direction?: "horizontal" | "vertical"; // Default: horizontal 6 | }; 7 | 8 | export const useMenuNavigation = ({ 9 | menuRef, 10 | direction = "horizontal", 11 | }: UseMenuNavigationProps) => { 12 | const activeElementRef = useRef(null); 13 | 14 | useEffect(() => { 15 | if (!menuRef.current) return; 16 | 17 | const focusableElements = Array.from( 18 | menuRef.current.querySelectorAll( 19 | 'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])' 20 | ) 21 | ) as HTMLElement[]; 22 | 23 | if (focusableElements.length === 0) return; 24 | 25 | const handleKeyDown = (e: KeyboardEvent) => { 26 | if (!activeElementRef.current) return; 27 | 28 | const currentIndex = focusableElements.indexOf(activeElementRef.current); 29 | let nextIndex = currentIndex; 30 | 31 | const isHorizontal = direction === "horizontal"; 32 | const forwardKey = isHorizontal ? "ArrowRight" : "ArrowDown"; 33 | const backwardKey = isHorizontal ? "ArrowLeft" : "ArrowUp"; 34 | 35 | if (e.key === forwardKey) { 36 | e.preventDefault(); 37 | nextIndex = (currentIndex + 1) % focusableElements.length; 38 | } else if (e.key === backwardKey) { 39 | e.preventDefault(); 40 | nextIndex = 41 | (currentIndex - 1 + focusableElements.length) % 42 | focusableElements.length; 43 | } else { 44 | return; 45 | } 46 | 47 | const nextElement = focusableElements[nextIndex]; 48 | activeElementRef.current = nextElement; 49 | nextElement.focus(); 50 | }; 51 | 52 | const addKeyListener = () => 53 | document.addEventListener("keydown", handleKeyDown); 54 | const removeKeyListener = () => 55 | document.removeEventListener("keydown", handleKeyDown); 56 | 57 | const handleFocusIn = () => { 58 | activeElementRef.current = document.activeElement as HTMLElement; 59 | addKeyListener(); 60 | }; 61 | 62 | const handleFocusOut = () => { 63 | activeElementRef.current = null; 64 | removeKeyListener(); 65 | }; 66 | 67 | menuRef.current.addEventListener("focusin", handleFocusIn); 68 | menuRef.current.addEventListener("focusout", handleFocusOut); 69 | 70 | return () => { 71 | menuRef.current?.removeEventListener("focusin", handleFocusIn); 72 | menuRef.current?.removeEventListener("focusout", handleFocusOut); 73 | removeKeyListener(); 74 | }; 75 | }, [menuRef, direction]); 76 | }; 77 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | const useTheme = (theme?: "dark" | "light") => { 4 | useEffect(() => { 5 | const html = document.querySelector("html"); 6 | 7 | if (theme === "dark") { 8 | html?.classList.add("dark"); 9 | } else if (theme === "light" && html?.classList.contains("dark")) 10 | html.classList.remove("dark"); 11 | }, [theme]); 12 | }; 13 | 14 | export default useTheme; 15 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/providers/ModalProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from "@/components/modal/Modal"; 2 | import { createContext, type ReactNode, useContext, useState } from "react"; 3 | 4 | type ModalContextType = { 5 | isOpen: boolean; 6 | content: ReactNode; 7 | openModal: (content: ReactNode) => void; 8 | closeModal: () => void; 9 | }; 10 | 11 | const ModalContext = createContext(undefined); 12 | 13 | export const ModalProvider = ({ children }: { children: ReactNode }) => { 14 | const [isOpen, setIsOpen] = useState(false); 15 | const [content, setContent] = useState(null); 16 | 17 | const openModal = (content: ReactNode) => { 18 | setContent(content); 19 | setIsOpen(true); 20 | }; 21 | 22 | const closeModal = () => { 23 | setIsOpen(false); 24 | setContent(null); 25 | }; 26 | 27 | return ( 28 | 29 | {children} 30 | {isOpen && ( 31 | 32 | {content} 33 | 34 | )} 35 | 36 | ); 37 | }; 38 | 39 | export const useModal = () => { 40 | const context = useContext(ModalContext); 41 | if (!context) { 42 | throw new Error("useModal must be used within a ModalProvider"); 43 | } 44 | return context; 45 | }; 46 | -------------------------------------------------------------------------------- /src/providers/TooltipProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useRef, useState } from "react"; 2 | 3 | type TooltipContextType = { 4 | activeTooltip: string | null; 5 | showTooltip: (id: string, isFocused: boolean) => void; 6 | hideTooltip: () => void; 7 | }; 8 | 9 | const TooltipContext = createContext(null); 10 | 11 | export const TooltipProvider = ({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }) => { 16 | const [activeTooltip, setActiveTooltip] = useState(null); 17 | const showTimeout = useRef(null); 18 | const graceTimeout = useRef(null); 19 | const isWithinGracePeriod = useRef(false); 20 | const isTooltipShown = useRef(false); 21 | 22 | const showTooltip = (id: string, isFocused: boolean) => { 23 | if (showTimeout.current) clearTimeout(showTimeout.current); 24 | if (graceTimeout.current) clearTimeout(graceTimeout.current); 25 | 26 | isTooltipShown.current = false; // Halt tooltips from auto-populating 27 | 28 | if (isFocused) { 29 | // Show tooltip immediately if the element has focus 30 | setActiveTooltip(id); 31 | isTooltipShown.current = true; 32 | } else if (isWithinGracePeriod.current) { 33 | // Show instantly if grace period is active 34 | setActiveTooltip(id); 35 | isTooltipShown.current = true; 36 | } else { 37 | // Apply delay before showing if not focused and not within grace period 38 | showTimeout.current = window.setTimeout(() => { 39 | setActiveTooltip(id); 40 | isTooltipShown.current = true; 41 | }, 600); 42 | } 43 | }; 44 | 45 | const hideTooltip = () => { 46 | if (showTimeout.current) clearTimeout(showTimeout.current); 47 | 48 | // Hide tooltip immediately when user leaves 49 | setActiveTooltip(null); 50 | 51 | if (isTooltipShown.current) { 52 | // Only start grace period if tooltip was actually shown 53 | isWithinGracePeriod.current = true; 54 | 55 | graceTimeout.current = window.setTimeout(() => { 56 | isWithinGracePeriod.current = false; // Grace period ends 57 | }, 100); 58 | } 59 | }; 60 | 61 | return ( 62 | 65 | {children} 66 | 67 | ); 68 | }; 69 | 70 | export const useTooltip = () => { 71 | const context = useContext(TooltipContext); 72 | if (!context) 73 | throw new Error("useTooltip must be used within TooltipProvider"); 74 | return context; 75 | }; 76 | -------------------------------------------------------------------------------- /src/providers/index.tsx: -------------------------------------------------------------------------------- 1 | import { ModalProvider } from "@/providers/ModalProvider"; 2 | import { TooltipProvider } from "@/providers/TooltipProvider"; 3 | 4 | export const Providers = ({ children }: { children: React.ReactNode }) => { 5 | return ( 6 | 7 | {children} 8 | 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { routeAgentRequest, type Schedule } from "agents"; 2 | 3 | import { unstable_getSchedulePrompt } from "agents/schedule"; 4 | 5 | import { AIChatAgent } from "agents/ai-chat-agent"; 6 | import { 7 | createDataStreamResponse, 8 | generateId, 9 | streamText, 10 | type StreamTextOnFinishCallback, 11 | type ToolSet, 12 | } from "ai"; 13 | import { openai } from "@ai-sdk/openai"; 14 | import { processToolCalls } from "./utils"; 15 | import { tools, executions } from "./tools"; 16 | // import { env } from "cloudflare:workers"; 17 | 18 | const model = openai("gpt-4o-2024-11-20"); 19 | // Cloudflare AI Gateway 20 | // const openai = createOpenAI({ 21 | // apiKey: env.OPENAI_API_KEY, 22 | // baseURL: env.GATEWAY_BASE_URL, 23 | // }); 24 | 25 | /** 26 | * Chat Agent implementation that handles real-time AI chat interactions 27 | */ 28 | export class Chat extends AIChatAgent { 29 | /** 30 | * Handles incoming chat messages and manages the response stream 31 | * @param onFinish - Callback function executed when streaming completes 32 | */ 33 | 34 | async onChatMessage( 35 | onFinish: StreamTextOnFinishCallback, 36 | options?: { abortSignal?: AbortSignal } 37 | ) { 38 | // const mcpConnection = await this.mcp.connect( 39 | // "https://path-to-mcp-server/sse" 40 | // ); 41 | 42 | // Collect all tools, including MCP tools 43 | const allTools = { 44 | ...tools, 45 | ...this.mcp.unstable_getAITools(), 46 | }; 47 | 48 | // Create a streaming response that handles both text and tool outputs 49 | const dataStreamResponse = createDataStreamResponse({ 50 | execute: async (dataStream) => { 51 | // Process any pending tool calls from previous messages 52 | // This handles human-in-the-loop confirmations for tools 53 | const processedMessages = await processToolCalls({ 54 | messages: this.messages, 55 | dataStream, 56 | tools: allTools, 57 | executions, 58 | }); 59 | 60 | // Stream the AI response using GPT-4 61 | const result = streamText({ 62 | model, 63 | system: `You are a helpful assistant that can do various tasks... 64 | 65 | ${unstable_getSchedulePrompt({ date: new Date() })} 66 | 67 | If the user asks to schedule a task, use the schedule tool to schedule the task. 68 | `, 69 | messages: processedMessages, 70 | tools: allTools, 71 | onFinish: async (args) => { 72 | onFinish( 73 | args as Parameters>[0] 74 | ); 75 | // await this.mcp.closeConnection(mcpConnection.id); 76 | }, 77 | onError: (error) => { 78 | console.error("Error while streaming:", error); 79 | }, 80 | maxSteps: 10, 81 | }); 82 | 83 | // Merge the AI response stream with tool execution outputs 84 | result.mergeIntoDataStream(dataStream); 85 | }, 86 | }); 87 | 88 | return dataStreamResponse; 89 | } 90 | async executeTask(description: string, task: Schedule) { 91 | await this.saveMessages([ 92 | ...this.messages, 93 | { 94 | id: generateId(), 95 | role: "user", 96 | content: `Running scheduled task: ${description}`, 97 | createdAt: new Date(), 98 | }, 99 | ]); 100 | } 101 | } 102 | 103 | /** 104 | * Worker entry point that routes incoming requests to the appropriate handler 105 | */ 106 | export default { 107 | async fetch(request: Request, env: Env, ctx: ExecutionContext) { 108 | const url = new URL(request.url); 109 | 110 | if (url.pathname === "/check-open-ai-key") { 111 | const hasOpenAIKey = !!process.env.OPENAI_API_KEY; 112 | return Response.json({ 113 | success: hasOpenAIKey, 114 | }); 115 | } 116 | if (!process.env.OPENAI_API_KEY) { 117 | console.error( 118 | "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" 119 | ); 120 | } 121 | return ( 122 | // Route the request to our agent or return 404 if not found 123 | (await routeAgentRequest(request, env)) || 124 | new Response("Not found", { status: 404 }) 125 | ); 126 | }, 127 | } satisfies ExportedHandler; 128 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | // Approval string to be shared across frontend and backend 2 | export const APPROVAL = { 3 | YES: "Yes, confirmed.", 4 | NO: "No, denied.", 5 | } as const; 6 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | /* Markdown Table Styling (Tailwind @apply) */ 4 | .markdown-body table { 5 | @apply w-full border-collapse my-4 text-[0.95em]; 6 | } 7 | .markdown-body th, 8 | .markdown-body td { 9 | @apply border border-neutral-300 dark:border-neutral-700 px-3 py-2 text-left; 10 | } 11 | .markdown-body th { 12 | @apply bg-neutral-100 dark:bg-neutral-800 font-semibold dark:text-neutral-200; 13 | } 14 | .markdown-body tr { 15 | @apply dark:text-neutral-300; 16 | } 17 | .markdown-body tr:nth-child(even) { 18 | @apply bg-neutral-50 dark:bg-neutral-850; 19 | } 20 | 21 | /* Custom variants */ 22 | @custom-variant dark (&:where(.dark, .dark *)); 23 | @custom-variant interactive (&:where(.interactive, .interactive *)); 24 | @custom-variant toggle (&:where(.toggle, .toggle *)); 25 | @custom-variant square (&:where(.square, .square *)); 26 | @custom-variant circular (&:where(.circular, .circular *)); 27 | 28 | /* Tailwind config */ 29 | @theme { 30 | /* Type */ 31 | --text-xs: 10px; 32 | --text-xs--line-height: calc(1 / 0.5); 33 | 34 | --text-sm: 12px; 35 | --text-sm--line-height: calc(1 / 0.75); 36 | 37 | --text-base: 14px; 38 | --text-base--line-height: calc(1.25 / 0.875); 39 | 40 | /* Easings & transitions */ 41 | --ease-bounce: cubic-bezier(0.2, 0, 0, 1.5); 42 | --default-transition-duration: 100ms /* snappier than default 150ms */; 43 | 44 | /* Animation */ 45 | --animate-refresh: refresh 0.5s ease-in-out infinite; 46 | 47 | /* Base colors */ 48 | --color-black: #000; 49 | --color-white: #fff; 50 | 51 | --color-neutral-50: oklch(0.985 0 0); 52 | --color-neutral-100: oklch(0.97 0 0); 53 | --color-neutral-150: oklch(0.96 0 0) /*new */; 54 | --color-neutral-200: oklch(0.922 0 0); 55 | --color-neutral-250: oklch(0.9 0 0) /* new */; 56 | --color-neutral-300: oklch(0.87 0 0); 57 | --color-neutral-400: oklch(0.708 0 0); 58 | --color-neutral-450: oklch(0.62 0 0) /* new */; 59 | --color-neutral-500: oklch(0.556 0 0); 60 | --color-neutral-600: oklch(0.439 0 0); 61 | --color-neutral-700: oklch(0.371 0 0); 62 | --color-neutral-750: oklch(0.31 0 0) /* new */; 63 | --color-neutral-800: oklch(0.269 0 0); 64 | --color-neutral-850: oklch(0.23 0 0) /* new */; 65 | --color-neutral-900: oklch(0.205 0 0); 66 | --color-neutral-925: oklch(0.175 0 0) /* new */; 67 | --color-neutral-950: oklch(0.145 0 0); 68 | 69 | --color-red-650: oklch(0.55 0.238 27.4); 70 | --color-red-750: oklch(0.46 0.195 27.2); 71 | 72 | --color-blue-400: oklch(0.707 0.165 254.624); 73 | --color-blue-800: oklch(0.424 0.199 265.638); 74 | 75 | /* Component colors */ 76 | 77 | /* Base colors */ 78 | --color-ob-base-100: var(--color-white); 79 | --color-ob-base-200: var(--color-neutral-50); 80 | --color-ob-base-300: var(--color-neutral-100); 81 | --color-ob-base-400: var(--color-neutral-200); 82 | --color-ob-base-500: var(--color-neutral-300); 83 | --color-ob-base-1000: var(--color-neutral-900); 84 | 85 | --color-ob-border: var(--color-neutral-200); 86 | --color-ob-border-active: var(--color-neutral-400); 87 | 88 | /* Text colors */ 89 | --text-color-ob-base-100: var(--color-neutral-500); 90 | --text-color-ob-base-200: var(--color-neutral-600); 91 | --text-color-ob-base-300: var(--color-neutral-900); 92 | --text-color-ob-destructive: var(--color-red-600); 93 | --text-color-ob-inverted: var(--color-white); 94 | 95 | /* ob-btn */ 96 | --color-ob-btn-primary-bg: var(--color-neutral-750); 97 | --color-ob-btn-primary-bg-hover: var(--color-neutral-850); 98 | --color-ob-btn-primary-border: var(--color-neutral-500); 99 | --color-ob-btn-primary-border-hover: var(--color-neutral-600); 100 | 101 | --color-ob-btn-secondary-bg: var(--color-white); 102 | --color-ob-btn-secondary-bg-hover: var(--color-neutral-50); 103 | --color-ob-btn-secondary-border: var(--color-neutral-200); 104 | --color-ob-btn-secondary-border-hover: var(--color-neutral-300); 105 | 106 | --color-ob-btn-ghost-bg-hover: var(--color-neutral-150); 107 | 108 | --color-ob-btn-destructive-bg: var(--color-red-600); 109 | --color-ob-btn-destructive-bg-hover: var(--color-red-650); 110 | --color-ob-btn-destructive-border: var(--color-red-400); 111 | --color-ob-btn-destructive-border-hover: var(--color-red-500); 112 | 113 | /* Focus colors */ 114 | --color-ob-focus: var(--color-blue-400); 115 | } 116 | 117 | .dark { 118 | /* Component colors */ 119 | 120 | /* Base colors */ 121 | --color-ob-base-100: var(--color-neutral-950); 122 | --color-ob-base-200: var(--color-neutral-900); 123 | --color-ob-base-300: var(--color-neutral-850); 124 | --color-ob-base-400: var(--color-neutral-800); 125 | --color-ob-base-500: var(--color-neutral-750); 126 | --color-ob-base-1000: var(--color-neutral-50); 127 | 128 | --color-ob-border: var(--color-neutral-800); 129 | --color-ob-border-active: var(--color-neutral-700); 130 | 131 | /* Text colors */ 132 | --text-color-ob-base-100: var(--color-neutral-500); 133 | --text-color-ob-base-200: var(--color-neutral-400); 134 | --text-color-ob-base-300: var(--color-neutral-50); 135 | --text-color-ob-destructive: var(--color-red-400); 136 | --text-color-ob-inverted: var(--color-neutral-900); 137 | 138 | /* ob-btn */ 139 | --color-ob-btn-primary-bg: var(--color-neutral-300); 140 | --color-ob-btn-primary-bg-hover: var(--color-neutral-250); 141 | --color-ob-btn-primary-border: var(--color-neutral-100); 142 | --color-ob-btn-primary-border-hover: var(--color-white); 143 | 144 | --color-ob-btn-secondary-bg: var(--color-neutral-900); 145 | --color-ob-btn-secondary-bg-hover: var(--color-neutral-850); 146 | --color-ob-btn-secondary-border: var(--color-neutral-800); 147 | --color-ob-btn-secondary-border-hover: var(--color-neutral-750); 148 | 149 | --color-ob-btn-ghost-bg-hover: var(--color-neutral-850); 150 | 151 | --color-ob-btn-destructive-bg: var(--color-red-800); 152 | --color-ob-btn-destructive-bg-hover: var(--color-red-750); 153 | --color-ob-btn-destructive-border: var(--color-red-700); 154 | --color-ob-btn-destructive-border-hover: var(--color-red-600); 155 | 156 | /* Focus colors */ 157 | --color-ob-focus: var(--color-blue-800); 158 | } 159 | 160 | .btn { 161 | &.btn-primary { 162 | @apply border-ob-btn-primary-border bg-ob-btn-primary-bg text-ob-inverted shadow-xs; 163 | 164 | @variant interactive { 165 | @apply not-disabled:hover:border-ob-btn-primary-border-hover not-disabled:hover:bg-ob-btn-primary-bg-hover; 166 | 167 | @variant toggle { 168 | @apply not-disabled:border-ob-btn-primary-border-hover not-disabled:bg-ob-btn-primary-bg-hover; 169 | } 170 | } 171 | } 172 | 173 | &.btn-secondary { 174 | @apply border-ob-btn-secondary-border bg-ob-btn-secondary-bg text-ob-base-300 shadow-xs; 175 | 176 | @variant interactive { 177 | @apply not-disabled:hover:border-ob-btn-secondary-border-hover not-disabled:hover:bg-ob-btn-secondary-bg-hover; 178 | 179 | @variant toggle { 180 | @apply not-disabled:border-ob-btn-secondary-border-hover not-disabled:bg-ob-btn-secondary-bg-hover; 181 | } 182 | } 183 | } 184 | 185 | &.btn-ghost { 186 | @apply text-ob-base-300 border-transparent bg-transparent; 187 | 188 | @variant interactive { 189 | @apply not-disabled:hover:bg-ob-btn-ghost-bg-hover; 190 | 191 | @variant toggle { 192 | @apply not-disabled:bg-ob-btn-ghost-bg-hover; 193 | } 194 | } 195 | } 196 | 197 | &.btn-destructive { 198 | @apply border-ob-btn-destructive-border bg-ob-btn-destructive-bg text-white; 199 | 200 | @variant interactive { 201 | @apply not-disabled:hover:bg-ob-btn-destructive-bg-hover not-disabled:hover:border-ob-btn-destructive-border-hover; 202 | 203 | @variant toggle { 204 | @apply not-disabled:bg-ob-btn-destructive-bg-hover not-disabled:border-ob-btn-destructive-border-hover; 205 | } 206 | } 207 | } 208 | 209 | @apply border; 210 | 211 | @variant interactive { 212 | @apply cursor-pointer transition-colors; 213 | } 214 | } 215 | 216 | /* Use for elements that require a tab-focus state (most elements) */ 217 | .add-focus { 218 | @apply focus-visible:ring-ob-focus outline-none focus:opacity-100 focus-visible:ring-1 *:in-focus:opacity-100; 219 | } 220 | 221 | /* Use for elements that require a disabled state */ 222 | .add-disable { 223 | @apply disabled:text-ob-base-100 disabled:cursor-not-allowed; 224 | } 225 | 226 | /* Use size variants for elements that need to match certain heights */ 227 | .add-size-sm { 228 | @apply h-6.5 rounded px-2 text-sm; 229 | 230 | @variant square { 231 | @apply flex size-6.5 items-center justify-center px-0; 232 | } 233 | 234 | @variant circular { 235 | @apply flex size-6.5 items-center justify-center rounded-full px-0; 236 | } 237 | } 238 | 239 | .add-size-md { 240 | @apply h-8 rounded-md px-2.5 text-base; 241 | 242 | @variant square { 243 | @apply flex size-8 items-center justify-center px-0; 244 | } 245 | 246 | @variant circular { 247 | @apply flex size-8 items-center justify-center rounded-full px-0; 248 | } 249 | } 250 | 251 | .add-size-base { 252 | @apply h-9 rounded-md px-3 text-base; 253 | 254 | @variant square { 255 | @apply flex size-9 items-center justify-center px-0; 256 | } 257 | 258 | @variant circular { 259 | @apply flex size-9 items-center justify-center rounded-full px-0; 260 | } 261 | } 262 | 263 | /* Database card animation */ 264 | .db-card { 265 | animation: db-card-animation 3s linear infinite; 266 | animation-play-state: paused; /* pause while group is not hovered */ 267 | stroke-dasharray: 100; /* length of each dash */ 268 | 269 | &:is(:where(.group):hover *) { 270 | @media (hover: hover) { 271 | animation-play-state: running; /* play while group is hovered */ 272 | } 273 | } 274 | } 275 | 276 | @keyframes db-card-animation { 277 | 0% { 278 | stroke-dashoffset: -200; 279 | } 280 | 50% { 281 | stroke-dashoffset: 0; 282 | } 283 | 100% { 284 | stroke-dashoffset: 200; 285 | } 286 | } 287 | 288 | /* SVG filters */ 289 | .pixelate { 290 | filter: url(#pixelate); 291 | } 292 | 293 | /* Ripple filter */ 294 | .ripple { 295 | filter: url(#ripple); 296 | } 297 | 298 | .float { 299 | animation: float 5s linear infinite alternate; 300 | } 301 | 302 | @keyframes float { 303 | to { 304 | transform: translate(5px, 15px); 305 | } 306 | } 307 | 308 | @keyframes refresh { 309 | to { 310 | transform: rotate(360deg) scale(0.9); 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tool definitions for the AI chat agent 3 | * Tools can either require human confirmation or execute automatically 4 | */ 5 | import { tool } from "ai"; 6 | import { z } from "zod"; 7 | 8 | import type { Chat } from "./server"; 9 | import { getCurrentAgent } from "agents"; 10 | import { unstable_scheduleSchema } from "agents/schedule"; 11 | 12 | /** 13 | * Weather information tool that requires human confirmation 14 | * When invoked, this will present a confirmation dialog to the user 15 | * The actual implementation is in the executions object below 16 | */ 17 | const getWeatherInformation = tool({ 18 | description: "show the weather in a given city to the user", 19 | parameters: z.object({ city: z.string() }), 20 | // Omitting execute function makes this tool require human confirmation 21 | }); 22 | 23 | /** 24 | * Local time tool that executes automatically 25 | * Since it includes an execute function, it will run without user confirmation 26 | * This is suitable for low-risk operations that don't need oversight 27 | */ 28 | const getLocalTime = tool({ 29 | description: "get the local time for a specified location", 30 | parameters: z.object({ location: z.string() }), 31 | execute: async ({ location }) => { 32 | console.log(`Getting local time for ${location}`); 33 | return "10am"; 34 | }, 35 | }); 36 | 37 | const scheduleTask = tool({ 38 | description: "A tool to schedule a task to be executed at a later time", 39 | parameters: unstable_scheduleSchema, 40 | execute: async ({ when, description }) => { 41 | // we can now read the agent context from the ALS store 42 | const { agent } = getCurrentAgent(); 43 | 44 | function throwError(msg: string): string { 45 | throw new Error(msg); 46 | } 47 | if (when.type === "no-schedule") { 48 | return "Not a valid schedule input"; 49 | } 50 | const input = 51 | when.type === "scheduled" 52 | ? when.date // scheduled 53 | : when.type === "delayed" 54 | ? when.delayInSeconds // delayed 55 | : when.type === "cron" 56 | ? when.cron // cron 57 | : throwError("not a valid schedule input"); 58 | try { 59 | agent!.schedule(input!, "executeTask", description); 60 | } catch (error) { 61 | console.error("error scheduling task", error); 62 | return `Error scheduling task: ${error}`; 63 | } 64 | return `Task scheduled for type "${when.type}" : ${input}`; 65 | }, 66 | }); 67 | 68 | /** 69 | * Tool to list all scheduled tasks 70 | * This executes automatically without requiring human confirmation 71 | */ 72 | const getScheduledTasks = tool({ 73 | description: "List all tasks that have been scheduled", 74 | parameters: z.object({}), 75 | execute: async () => { 76 | const { agent } = getCurrentAgent(); 77 | 78 | try { 79 | const tasks = agent!.getSchedules(); 80 | if (!tasks || tasks.length === 0) { 81 | return "No scheduled tasks found."; 82 | } 83 | return tasks; 84 | } catch (error) { 85 | console.error("Error listing scheduled tasks", error); 86 | return `Error listing scheduled tasks: ${error}`; 87 | } 88 | }, 89 | }); 90 | 91 | /** 92 | * Tool to cancel a scheduled task by its ID 93 | * This executes automatically without requiring human confirmation 94 | */ 95 | const cancelScheduledTask = tool({ 96 | description: "Cancel a scheduled task using its ID", 97 | parameters: z.object({ 98 | taskId: z.string().describe("The ID of the task to cancel"), 99 | }), 100 | execute: async ({ taskId }) => { 101 | const { agent } = getCurrentAgent(); 102 | try { 103 | await agent!.cancelSchedule(taskId); 104 | return `Task ${taskId} has been successfully canceled.`; 105 | } catch (error) { 106 | console.error("Error canceling scheduled task", error); 107 | return `Error canceling task ${taskId}: ${error}`; 108 | } 109 | }, 110 | }); 111 | 112 | /** 113 | * Export all available tools 114 | * These will be provided to the AI model to describe available capabilities 115 | */ 116 | export const tools = { 117 | getWeatherInformation, 118 | getLocalTime, 119 | scheduleTask, 120 | getScheduledTasks, 121 | cancelScheduledTask, 122 | }; 123 | 124 | /** 125 | * Implementation of confirmation-required tools 126 | * This object contains the actual logic for tools that need human approval 127 | * Each function here corresponds to a tool above that doesn't have an execute function 128 | */ 129 | export const executions = { 130 | getWeatherInformation: async ({ city }: { city: string }) => { 131 | console.log(`Getting weather information for ${city}`); 132 | return `The weather in ${city} is sunny`; 133 | }, 134 | }; 135 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | // via https://github.com/vercel/ai/blob/main/examples/next-openai/app/api/use-chat-human-in-the-loop/utils.ts 2 | 3 | import { formatDataStreamPart, type Message } from "@ai-sdk/ui-utils"; 4 | import { 5 | convertToCoreMessages, 6 | type DataStreamWriter, 7 | type ToolExecutionOptions, 8 | type ToolSet, 9 | } from "ai"; 10 | import type { z } from "zod"; 11 | import { APPROVAL } from "./shared"; 12 | 13 | function isValidToolName( 14 | key: K, 15 | obj: T 16 | ): key is K & keyof T { 17 | return key in obj; 18 | } 19 | 20 | /** 21 | * Processes tool invocations where human input is required, executing tools when authorized. 22 | * 23 | * @param options - The function options 24 | * @param options.tools - Map of tool names to Tool instances that may expose execute functions 25 | * @param options.dataStream - Data stream for sending results back to the client 26 | * @param options.messages - Array of messages to process 27 | * @param executionFunctions - Map of tool names to execute functions 28 | * @returns Promise resolving to the processed messages 29 | */ 30 | export async function processToolCalls< 31 | Tools extends ToolSet, 32 | ExecutableTools extends { 33 | // biome-ignore lint/complexity/noBannedTypes: it's fine 34 | [Tool in keyof Tools as Tools[Tool] extends { execute: Function } 35 | ? never 36 | : Tool]: Tools[Tool]; 37 | }, 38 | >({ 39 | dataStream, 40 | messages, 41 | executions, 42 | }: { 43 | tools: Tools; // used for type inference 44 | dataStream: DataStreamWriter; 45 | messages: Message[]; 46 | executions: { 47 | [K in keyof Tools & keyof ExecutableTools]?: ( 48 | args: z.infer, 49 | context: ToolExecutionOptions 50 | ) => Promise; 51 | }; 52 | }): Promise { 53 | const lastMessage = messages[messages.length - 1]; 54 | const parts = lastMessage.parts; 55 | if (!parts) return messages; 56 | 57 | const processedParts = await Promise.all( 58 | parts.map(async (part) => { 59 | // Only process tool invocations parts 60 | if (part.type !== "tool-invocation") return part; 61 | 62 | const { toolInvocation } = part; 63 | const toolName = toolInvocation.toolName; 64 | 65 | // Only continue if we have an execute function for the tool (meaning it requires confirmation) and it's in a 'result' state 66 | if (!(toolName in executions) || toolInvocation.state !== "result") 67 | return part; 68 | 69 | let result: unknown; 70 | 71 | if (toolInvocation.result === APPROVAL.YES) { 72 | // Get the tool and check if the tool has an execute function. 73 | if ( 74 | !isValidToolName(toolName, executions) || 75 | toolInvocation.state !== "result" 76 | ) { 77 | return part; 78 | } 79 | 80 | const toolInstance = executions[toolName]; 81 | if (toolInstance) { 82 | result = await toolInstance(toolInvocation.args, { 83 | messages: convertToCoreMessages(messages), 84 | toolCallId: toolInvocation.toolCallId, 85 | }); 86 | } else { 87 | result = "Error: No execute function found on tool"; 88 | } 89 | } else if (toolInvocation.result === APPROVAL.NO) { 90 | result = "Error: User denied access to tool execution"; 91 | } else { 92 | // For any unhandled responses, return the original part. 93 | return part; 94 | } 95 | 96 | // Forward updated tool result to the client. 97 | dataStream.write( 98 | formatDataStreamPart("tool_result", { 99 | toolCallId: toolInvocation.toolCallId, 100 | result, 101 | }) 102 | ); 103 | 104 | // Return updated toolInvocation with the actual result. 105 | return { 106 | ...part, 107 | toolInvocation: { 108 | ...toolInvocation, 109 | result, 110 | }, 111 | }; 112 | }) 113 | ); 114 | 115 | // Finally return the processed messages 116 | return [...messages.slice(0, -1), { ...lastMessage, parts: processedParts }]; 117 | } 118 | 119 | // export function getToolsRequiringConfirmation< 120 | // T extends ToolSet 121 | // // E extends { 122 | // // [K in keyof T as T[K] extends { execute: Function } ? never : K]: T[K]; 123 | // // }, 124 | // >(tools: T): string[] { 125 | // return (Object.keys(tools) as (keyof T)[]).filter((key) => { 126 | // const maybeTool = tools[key]; 127 | // return typeof maybeTool.execute !== "function"; 128 | // }) as string[]; 129 | // } 130 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | env, 3 | createExecutionContext, 4 | waitOnExecutionContext, 5 | } from "cloudflare:test"; 6 | import { describe, it, expect } from "vitest"; 7 | // Could import any other source file/function here 8 | import worker from "../src/server"; 9 | 10 | declare module "cloudflare:test" { 11 | // Controls the type of `import("cloudflare:test").env` 12 | interface ProvidedEnv extends Env {} 13 | } 14 | 15 | describe("Chat worker", () => { 16 | it("responds with Not found", async () => { 17 | const request = new Request("http://example.com"); 18 | // Create an empty context to pass to `worker.fetch()` 19 | const ctx = createExecutionContext(); 20 | const response = await worker.fetch(request, env, ctx); 21 | // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions 22 | await waitOnExecutionContext(ctx); 23 | expect(await response.text()).toBe("Not found"); 24 | expect(response.status).toBe(404); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["../worker-configuration.d.ts", "@cloudflare/vitest-pool-workers"] 5 | }, 6 | "include": ["./**/*.ts"], 7 | "exclude": ["../src"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Tailwind configuration 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["./src/*"] 7 | }, 8 | 9 | /* Visit https://aka.ms/tsconfig to read more about this file */ 10 | 11 | /* Projects */ 12 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 13 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 14 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 15 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 16 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 17 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 18 | 19 | /* Language and Environment */ 20 | "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 21 | "lib": [ 22 | "ES2021", 23 | "DOM" 24 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 25 | "jsx": "react-jsx" /* Specify what JSX code is generated. */, 26 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 27 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 28 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 29 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 30 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 31 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 32 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 33 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 34 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 35 | 36 | /* Modules */ 37 | "module": "ES2022" /* Specify what module code is generated. */, 38 | // "rootDir": "./", /* Specify the root folder within your source files. */ 39 | "moduleResolution": "Bundler" /* Specify how TypeScript looks up a file from a given module specifier. */, 40 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 41 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 42 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 43 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 44 | "types": [ 45 | "node", 46 | "vite/client", 47 | "./worker-configuration.d.ts" 48 | ] /* Specify type package names to be included without being referenced in a source file. */, 49 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 50 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 51 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 52 | // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ 53 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 54 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 55 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 56 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ 57 | // "resolveJsonModule": true, /* Enable importing .json files. */ 58 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 59 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 60 | 61 | /* JavaScript Support */ 62 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 63 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 64 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 65 | 66 | /* Emit */ 67 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 68 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 69 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 70 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 71 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 72 | "noEmit": true /* Disable emitting files from a compilation. */, 73 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 74 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 75 | // "removeComments": true, /* Disable emitting comments. */ 76 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 77 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 78 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 79 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 80 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 81 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 82 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 83 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 84 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 85 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 86 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 87 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 88 | 89 | /* Interop Constraints */ 90 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, 91 | "verbatimModuleSyntax": true /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */, 92 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 93 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 94 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 95 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 96 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 97 | 98 | /* Type Checking */ 99 | "strict": true /* Enable all strict type-checking options. */, 100 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 101 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 102 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 103 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 104 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 105 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ 106 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 107 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 108 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 109 | "noUnusedLocals": true /* Enable error reporting when local variables aren't read. */, 110 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 111 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 112 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 113 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 114 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 115 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 116 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 117 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 118 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 119 | 120 | /* Completeness */ 121 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 122 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 123 | }, 124 | "exclude": ["tests"] 125 | } 126 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react"; 4 | import { cloudflare } from "@cloudflare/vite-plugin"; 5 | import tailwindcss from "@tailwindcss/vite"; 6 | 7 | export default defineConfig({ 8 | plugins: [cloudflare(), react(), tailwindcss()], 9 | resolve: { 10 | alias: { 11 | "@": path.resolve(__dirname, "./src"), 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; 2 | 3 | export default defineWorkersConfig({ 4 | environments: { 5 | ssr: { 6 | keepProcessEnv: true, 7 | }, 8 | }, 9 | test: { 10 | poolOptions: { 11 | workers: { 12 | wrangler: { configPath: "./wrangler.jsonc" }, 13 | }, 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-chat-agent", 3 | "main": "src/server.ts", 4 | "compatibility_date": "2025-02-04", 5 | "compatibility_flags": [ 6 | "nodejs_compat", 7 | "nodejs_compat_populate_process_env", 8 | ], 9 | "assets": { 10 | "directory": "public", 11 | }, 12 | "durable_objects": { 13 | "bindings": [ 14 | { 15 | "name": "Chat", 16 | "class_name": "Chat", 17 | }, 18 | ], 19 | }, 20 | "migrations": [ 21 | { 22 | "tag": "v1", 23 | "new_sqlite_classes": ["Chat"], 24 | }, 25 | ], 26 | } 27 | --------------------------------------------------------------------------------