├── .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 | Seek Understanding
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 |
Increment
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 | 
4 |
5 |
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 | You need to enable JavaScript to run this app.
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 |
147 | {theme === "dark" ? : }
148 |
149 |
150 |
157 |
158 |
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 |
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 |
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 |
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 |
8 |
16 |
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 |
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 |
173 | {item.label}
174 |
179 | {item.icon}
180 |
181 |
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 |
31 |
32 | {title}
33 | {required && (
34 | <>
35 | *
36 | {requiredDescription && !isValid && (
37 |
38 | {requiredDescription}
39 |
40 | )}
41 | >
42 | )}
43 |
44 | {children}
45 |
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 |
38 | {icon}
39 |
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 |
68 | {options.map((option, index) => (
69 |
76 | ))}
77 |
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 |
101 |
102 |
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 ;
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 | toggleTheme()}
20 | >
21 |
27 |
33 |
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 | ) => {
30 | if (e.pointerType === "mouse") {
31 | setIsPointer(true);
32 | }
33 | }}
34 | onBlur={() => setIsPointer(false)}
35 | className={cn(
36 | "btn btn-secondary interactive relative appearance-none truncate bg-no-repeat focus:outline-none",
37 | {
38 | "add-size-sm !pr-6.5": size === "sm",
39 | "add-size-md !pr-8": size === "md",
40 | "add-size-base !pr-9": size === "base",
41 | "add-focus": isPointer === false,
42 | },
43 | className
44 | )}
45 | style={{
46 | backgroundImage: "url(/assets/caret.svg)",
47 | backgroundPosition: `calc(100% - ${size === "base" ? "10px" : size === "md" ? "8px" : "6px"}) calc(100% / 2)`,
48 | backgroundSize:
49 | size === "base" ? "16px" : size === "md" ? "14px" : "12px",
50 | }}
51 | onChange={(e) => {
52 | setValue(e.target.value);
53 | e.target.blur();
54 | }}
55 | value={value}
56 | >
57 | {placeholder && {placeholder} }
58 | {options.map((option, index) => (
59 | // biome-ignore lint/suspicious/noArrayIndexKey: TODO
60 |
61 | {option.value}
62 |
63 | ))}
64 |
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 |
25 |
33 |
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 | setIsExpanded(!isExpanded)}
43 | className="w-full flex items-center gap-2 cursor-pointer"
44 | >
45 |
48 |
49 |
50 |
51 | {toolInvocation.toolName}
52 | {!needsConfirmation && toolInvocation.state === "result" && (
53 | ✓ Completed
54 | )}
55 |
56 |
60 |
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 |
84 | addToolResult({
85 | toolCallId,
86 | result: APPROVAL.NO,
87 | })
88 | }
89 | >
90 | Reject
91 |
92 |
93 |
97 | addToolResult({
98 | toolCallId,
99 | result: APPROVAL.YES,
100 | })
101 | }
102 | >
103 | Approve
104 |
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 |
--------------------------------------------------------------------------------