├── LICENSE ├── README.md ├── backend ├── .dev.vars.example ├── .gitignore ├── biome.json ├── drizzle.config.ts ├── drizzle │ ├── migrations │ │ ├── 0000_red_triathlon.sql │ │ ├── index.ts │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ └── schema.ts ├── generate-migrations-index.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src │ ├── connect.ts │ ├── connectrpc-handler.ts │ ├── durable.ts │ ├── gen │ │ └── chat │ │ │ └── v1 │ │ │ └── chat_pb.ts │ ├── index.ts │ ├── prompts │ │ └── tts.ts │ └── store-context.ts ├── tsconfig.json ├── worker-configuration.d.ts └── wrangler.jsonc ├── buf.gen.yaml ├── buf.yaml ├── deploy.sh ├── frontend ├── .env ├── .gitignore ├── README.md ├── app │ ├── components │ │ ├── chat-bubble.tsx │ │ ├── chat-input.tsx │ │ ├── conversation-item.tsx │ │ ├── mobile-drawer.tsx │ │ ├── models-menu.tsx │ │ ├── rename-dialog.tsx │ │ ├── sidebar.tsx │ │ └── ui │ │ │ ├── prose.tsx │ │ │ └── toaster.tsx │ ├── connect.ts │ ├── gen │ │ └── chat │ │ │ └── v1 │ │ │ └── chat_pb.ts │ ├── lib │ │ ├── mcp.ts │ │ └── websocket.ts │ ├── root.tsx │ ├── routes.ts │ ├── routes │ │ ├── chat.tsx │ │ ├── knowledges.tsx │ │ └── layout.tsx │ ├── stores │ │ ├── chat.ts │ │ └── ui.ts │ ├── types │ │ └── index.ts │ └── utils │ │ ├── conversation.ts │ │ └── query.tsx ├── biome.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public │ └── favicon.ico ├── react-router.config.ts ├── tsconfig.json └── vite.config.ts └── proto └── chat └── v1 └── chat.proto /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 akazwz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare AI Chat Demo 2 | 3 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/akazwz/workersai/tree/main/backend) 4 | 5 | Live Demo: [workersai.zwz.workers.dev](https://workersai.zwz.workers.dev/) 6 | 7 | This is a demo project showcasing a full-stack chat application built entirely on the Cloudflare stack, featuring AI capabilities. 8 | 9 | ## Features 10 | 11 | * **AI-Powered Chat:** Engage in conversations powered by Cloudflare AI text generation models. 12 | * **Real-time Communication:** Uses WebSockets via Cloudflare Durable Objects for instant message delivery. 13 | * **Text-to-Speech (TTS):** Hear AI responses spoken aloud using Cloudflare AI TTS. 14 | * **Speech-to-Text (STT):** Dictate your messages using Cloudflare AI STT (Whisper). 15 | * **Conversation Management:** Create, rename, pin, and delete conversations. 16 | * **Model Selection:** View available Cloudflare AI text generation models. 17 | * **Anonymous Sessions:** Simple authentication using tokens stored in Cloudflare KV. 18 | * **Modern Tech Stack:** 19 | * **Backend:** Cloudflare Workers, Durable Objects, KV, AI Gateway, TypeScript, ConnectRPC, Drizzle ORM (potentially for future database integration). 20 | * **Frontend:** React, TypeScript, Vite, TailwindCSS, React Router. 21 | * **API:** Protocol Buffers (protobuf) for type-safe communication. 22 | 23 | ## Project Structure 24 | 25 | ``` 26 | . 27 | ├── backend/ # Cloudflare Worker backend code (TypeScript) 28 | ├── frontend/ # React frontend application (TypeScript, Vite) 29 | ├── proto/ # Protocol Buffer definitions for the API 30 | ├── buf.gen.yaml # Buf code generation configuration 31 | ├── buf.yaml # Buf linting and breaking change detection configuration 32 | ├── deploy.sh # Deployment script (likely for Cloudflare Workers) 33 | └── README.md # This file 34 | ``` 35 | 36 | ## Getting Started 37 | 38 | *(Instructions need to be added here based on how to set up environment variables, install dependencies for both frontend and backend, and run the development servers.)* 39 | 40 | ### Prerequisites 41 | 42 | * Node.js and pnpm 43 | * Cloudflare Account 44 | * Wrangler CLI 45 | * Buf CLI (optional, for protobuf generation) 46 | 47 | ### Setup 48 | 49 | 1. **Clone the repository:** 50 | ```bash 51 | git clone https://github.com/akazwz/workersai.git 52 | cd workersai 53 | ``` 54 | 2. **Install Dependencies:** 55 | ```bash 56 | # From the root directory 57 | pnpm install 58 | # Navigate to backend and frontend to ensure all deps are installed if needed 59 | cd backend && pnpm install 60 | cd ../frontend && pnpm install 61 | cd .. 62 | ``` 63 | 3. **Configure Backend Environment Variables:** 64 | * Navigate to the `backend` directory. 65 | * Copy the provided example environment file: `cp .dev.vars.example .dev.vars` 66 | * Edit `.dev.vars` and fill in your Cloudflare credentials and bindings (refer to `wrangler.jsonc` for required variables). 67 | * Return to the project root directory: `cd ..` 68 | 4. **Configure Frontend Environment Variables:** 69 | * The `frontend` directory now includes a `.env` file with default/example settings. 70 | * For local development, it is recommended to copy this file to `.env.local` within the `frontend` directory: 71 | ```bash 72 | cp frontend/.env frontend/.env.local 73 | ``` 74 | * Then, customize `frontend/.env.local` with your specific settings (e.g., `VITE_API_URL` if it needs to differ from the default). The `.env.local` file will override `frontend/.env` and is typically gitignored. 75 | * If you don't create a `.env.local`, ensure the values in `frontend/.env` are suitable for your local setup. 76 | 5. **Build Frontend Assets:** 77 | ```bash 78 | cd frontend 79 | pnpm run build 80 | cd .. 81 | ``` 82 | 6. **Configure Cloudflare Resources:** 83 | * Set up necessary Cloudflare resources (KV namespace, Durable Object binding, AI Gateway). This step might involve using the Cloudflare dashboard or Wrangler commands. Ensure the bindings in `backend/wrangler.jsonc` and `.dev.vars` match these resources. 84 | 7. **Generate Protobuf Code (if needed):** 85 | ```bash 86 | buf generate 87 | ``` 88 | 89 | ### Running Locally 90 | 91 | **Important:** Before starting the backend worker, ensure you have: 92 | 1. Configured your `backend/.dev.vars` file (as per **Setup step 3**). 93 | 2. Configured your frontend environment by checking/copying `frontend/.env` to `frontend/.env.local` and customizing it if needed (as per **Setup step 4**). 94 | 3. Built the frontend assets (as per **Setup step 5**): 95 | ```bash 96 | # Ensure you are in the project root directory 97 | cd frontend 98 | pnpm run build 99 | cd .. 100 | ``` 101 | These steps are crucial for the application to run correctly. 102 | 103 | 1. **Start the backend worker (using Wrangler):** 104 | ```bash 105 | cd backend 106 | pnpm run dev 107 | ``` 108 | 2. **Start the frontend development server (optional, if you want to make frontend changes and see them live):** 109 | ```bash 110 | cd frontend 111 | pnpm run dev 112 | ``` 113 | 114 | The application should now be accessible (usually at `http://localhost:8787` for the backend worker, which serves the frontend assets. If you run the frontend dev server, it's often at `http://localhost:5173`). 115 | 116 | ## Deployment 117 | 118 | *(Instructions need to be added here, likely involving running the `deploy.sh` script or using `wrangler deploy`.)* 119 | 120 | ```bash 121 | # Example deployment command (adapt as needed) 122 | ./deploy.sh 123 | # or 124 | # cd backend && wrangler deploy 125 | ``` 126 | 127 | ## Contributing 128 | 129 | Contributions are welcome! Please feel free to submit issues or pull requests. 130 | 131 | ## License 132 | 133 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /backend/.dev.vars.example: -------------------------------------------------------------------------------- 1 | CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id 2 | CLOUDFLARE_AI_GATEWAY_ID=your_cloudflare_ai_gateway_name 3 | CLOUDFLARE_AI_GATEWAY_TOKEN=your_cloudflare_ai_gateway_token 4 | CLOUDFLARE_WORKERS_AI_TOKEN=your_workers_ai_token -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | -------------------------------------------------------------------------------- /backend/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": ["**/gen/**"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true 23 | } 24 | }, 25 | "javascript": { 26 | "formatter": { 27 | "quoteStyle": "double" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | dialect: "sqlite", 5 | schema: "drizzle/schema.ts", 6 | out: "drizzle/migrations", 7 | casing: "snake_case", 8 | }); 9 | -------------------------------------------------------------------------------- /backend/drizzle/migrations/0000_red_triathlon.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `conversations` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `user_id` text NOT NULL, 4 | `title` text, 5 | `pinned` integer DEFAULT false, 6 | `created_at` text, 7 | `updated_at` text 8 | ); 9 | --> statement-breakpoint 10 | CREATE INDEX `conversation_user_id` ON `conversations` (`user_id`);--> statement-breakpoint 11 | CREATE INDEX `conversation_pinned` ON `conversations` (`pinned`);--> statement-breakpoint 12 | CREATE TABLE `messages` ( 13 | `id` text PRIMARY KEY NOT NULL, 14 | `user_id` text NOT NULL, 15 | `conversation_id` text NOT NULL, 16 | `role` text NOT NULL, 17 | `content` text NOT NULL, 18 | `created_at` text, 19 | `updated_at` text 20 | ); 21 | --> statement-breakpoint 22 | CREATE INDEX `message_conversation_id` ON `messages` (`conversation_id`);--> statement-breakpoint 23 | CREATE INDEX `message_user_id` ON `messages` (`user_id`); -------------------------------------------------------------------------------- /backend/drizzle/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import journal from "./meta/_journal.json"; 2 | import m0000 from "./0000_red_triathlon.sql"; 3 | 4 | export default { 5 | journal, 6 | migrations: { 7 | m0000, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /backend/drizzle/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "93a13abf-fdd8-47c1-8c4b-bff7bb729fc7", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "conversations": { 8 | "name": "conversations", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "title": { 25 | "name": "title", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false, 29 | "autoincrement": false 30 | }, 31 | "pinned": { 32 | "name": "pinned", 33 | "type": "integer", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false, 37 | "default": false 38 | }, 39 | "created_at": { 40 | "name": "created_at", 41 | "type": "text", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false 45 | }, 46 | "updated_at": { 47 | "name": "updated_at", 48 | "type": "text", 49 | "primaryKey": false, 50 | "notNull": false, 51 | "autoincrement": false 52 | } 53 | }, 54 | "indexes": { 55 | "conversation_user_id": { 56 | "name": "conversation_user_id", 57 | "columns": ["user_id"], 58 | "isUnique": false 59 | }, 60 | "conversation_pinned": { 61 | "name": "conversation_pinned", 62 | "columns": ["pinned"], 63 | "isUnique": false 64 | } 65 | }, 66 | "foreignKeys": {}, 67 | "compositePrimaryKeys": {}, 68 | "uniqueConstraints": {}, 69 | "checkConstraints": {} 70 | }, 71 | "messages": { 72 | "name": "messages", 73 | "columns": { 74 | "id": { 75 | "name": "id", 76 | "type": "text", 77 | "primaryKey": true, 78 | "notNull": true, 79 | "autoincrement": false 80 | }, 81 | "user_id": { 82 | "name": "user_id", 83 | "type": "text", 84 | "primaryKey": false, 85 | "notNull": true, 86 | "autoincrement": false 87 | }, 88 | "conversation_id": { 89 | "name": "conversation_id", 90 | "type": "text", 91 | "primaryKey": false, 92 | "notNull": true, 93 | "autoincrement": false 94 | }, 95 | "role": { 96 | "name": "role", 97 | "type": "text", 98 | "primaryKey": false, 99 | "notNull": true, 100 | "autoincrement": false 101 | }, 102 | "content": { 103 | "name": "content", 104 | "type": "text", 105 | "primaryKey": false, 106 | "notNull": true, 107 | "autoincrement": false 108 | }, 109 | "created_at": { 110 | "name": "created_at", 111 | "type": "text", 112 | "primaryKey": false, 113 | "notNull": false, 114 | "autoincrement": false 115 | }, 116 | "updated_at": { 117 | "name": "updated_at", 118 | "type": "text", 119 | "primaryKey": false, 120 | "notNull": false, 121 | "autoincrement": false 122 | } 123 | }, 124 | "indexes": { 125 | "message_conversation_id": { 126 | "name": "message_conversation_id", 127 | "columns": ["conversation_id"], 128 | "isUnique": false 129 | }, 130 | "message_user_id": { 131 | "name": "message_user_id", 132 | "columns": ["user_id"], 133 | "isUnique": false 134 | } 135 | }, 136 | "foreignKeys": {}, 137 | "compositePrimaryKeys": {}, 138 | "uniqueConstraints": {}, 139 | "checkConstraints": {} 140 | } 141 | }, 142 | "views": {}, 143 | "enums": {}, 144 | "_meta": { 145 | "schemas": {}, 146 | "tables": {}, 147 | "columns": {} 148 | }, 149 | "internal": { 150 | "indexes": {} 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /backend/drizzle/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1745979319027, 9 | "tag": "0000_red_triathlon", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /backend/drizzle/schema.ts: -------------------------------------------------------------------------------- 1 | import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | 3 | export const conversations = sqliteTable( 4 | "conversations", 5 | { 6 | id: text() 7 | .primaryKey() 8 | .$default(() => crypto.randomUUID()), 9 | user_id: text().notNull(), 10 | title: text(), 11 | pinned: integer({ mode: "boolean" }).default(false), 12 | created_at: text().$default(() => new Date().toISOString()), 13 | updated_at: text().$default(() => new Date().toISOString()), 14 | }, 15 | (table) => [ 16 | index("conversation_user_id").on(table.user_id), 17 | index("conversation_pinned").on(table.pinned), 18 | ], 19 | ); 20 | 21 | export const messages = sqliteTable( 22 | "messages", 23 | { 24 | id: text() 25 | .primaryKey() 26 | .$default(() => crypto.randomUUID()), 27 | user_id: text().notNull(), 28 | conversation_id: text().notNull(), 29 | role: text({ enum: ["user", "assistant"] }).notNull(), 30 | content: text().notNull(), 31 | created_at: text().$default(() => new Date().toISOString()), 32 | updated_at: text().$default(() => new Date().toISOString()), 33 | }, 34 | (table) => [ 35 | index("message_conversation_id").on(table.conversation_id), 36 | index("message_user_id").on(table.user_id), 37 | ], 38 | ); 39 | -------------------------------------------------------------------------------- /backend/generate-migrations-index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const migrationsDir = path.join(__dirname, "drizzle", "migrations"); 5 | const indexPath = path.join(migrationsDir, "index.ts"); 6 | 7 | // Get all SQL migration files 8 | const migrationFiles = fs 9 | .readdirSync(migrationsDir) 10 | .filter((file) => file.endsWith(".sql")) 11 | .sort(); // Natural sort order 12 | 13 | // Generate variable names from filenames (m0000, m0001, etc.) 14 | const migrations = migrationFiles.map((file) => { 15 | const name = "m" + file.split("_")[0]; // Extract the number part 16 | return { name, file }; 17 | }); 18 | 19 | // Generate the index.ts content 20 | let indexContent = `import journal from "./meta/_journal.json";\n`; 21 | 22 | // Add imports 23 | migrations.forEach((migration) => { 24 | indexContent += `import ${migration.name} from "./${migration.file}";\n`; 25 | }); 26 | 27 | // Add exports 28 | indexContent += `\nexport default {\n\tjournal,\n\tmigrations: {\n`; 29 | migrations.forEach((migration, i) => { 30 | indexContent += `\t\t${migration.name}${i < migrations.length - 1 ? "," : ""}\n`; 31 | }); 32 | indexContent += `\t},\n};\n`; 33 | 34 | // Write the file 35 | fs.writeFileSync(indexPath, indexContent); 36 | 37 | console.log(`Generated index.ts with ${migrations.length} migrations`); 38 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dove", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "cf-typegen": "wrangler types", 10 | "format": "biome format --write .", 11 | "db:generate": "drizzle-kit generate && node generate-migrations-index.js" 12 | }, 13 | "devDependencies": { 14 | "@biomejs/biome": "^1.9.4", 15 | "drizzle-kit": "^0.31.1", 16 | "typescript": "^5.8.3", 17 | "wrangler": "^4.14.1" 18 | }, 19 | "dependencies": { 20 | "@bufbuild/protobuf": "^2.2.5", 21 | "@connectrpc/connect": "^2.0.2", 22 | "drizzle-orm": "^0.43.1", 23 | "openai": "^4.97.0", 24 | "zod": "^3.24.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - esbuild 3 | - workerd 4 | -------------------------------------------------------------------------------- /backend/src/connect.ts: -------------------------------------------------------------------------------- 1 | import { env } from "cloudflare:workers"; 2 | import { 3 | Code, 4 | ConnectError, 5 | createContextValues, 6 | HandlerContext, 7 | Interceptor, 8 | } from "@connectrpc/connect"; 9 | import { create } from "@bufbuild/protobuf"; 10 | import { z } from "zod"; 11 | import { zodResponseFormat } from "openai/helpers/zod"; 12 | 13 | import { createWorkerHandler } from "~/connectrpc-handler"; 14 | import { 15 | AnonymousRegisterResponseSchema, 16 | ChatService, 17 | CreateConversationResponseSchema, 18 | DeleteConversationResponseSchema, 19 | ListConversationsResponseSchema, 20 | ListMessagesResponseSchema, 21 | ListModelsResponseSchema, 22 | PinConversationResponseSchema, 23 | RenameConversationResponseSchema, 24 | SpeechToTextResponseSchema, 25 | StreamTTSResponseSchema, 26 | UnpinConversationResponseSchema, 27 | } from "~/gen/chat/v1/chat_pb"; 28 | import { userStore } from "~/store-context"; 29 | import { getTTSChunkingPrompt } from "~/prompts/tts"; 30 | 31 | const TTSInputSchema = z.object({ 32 | chunks: z.array(z.string()), 33 | }); 34 | 35 | const TTS_SHORT_TEXT_WORD_THRESHOLD = 100; 36 | 37 | const PUBLIC_ROUTES = ["anonymousRegister"]; 38 | 39 | const anonymousInterceptor: Interceptor = (next) => async (req) => { 40 | if (PUBLIC_ROUTES.includes(req.method.name)) { 41 | return next(req); 42 | } 43 | const accessToken = req.header.get("authorization"); 44 | if (accessToken) { 45 | const token = await env.KV.get(`anonymous_access_token:${accessToken}`); 46 | if (!token) { 47 | throw new ConnectError("Unauthorized", Code.Unauthenticated); 48 | } 49 | } 50 | const userCtx = req.contextValues.get(userStore); 51 | if (!userCtx) { 52 | throw new ConnectError("No user context", Code.Internal); 53 | } 54 | req.contextValues.set(userStore, { 55 | accessToken, 56 | }); 57 | return next(req); 58 | }; 59 | 60 | function getUserAccessToken(ctx: HandlerContext) { 61 | const userCtx = ctx.values.get(userStore); 62 | if (!userCtx) { 63 | throw new ConnectError("No user context", Code.Internal); 64 | } 65 | return userCtx.accessToken; 66 | } 67 | 68 | export const handler = createWorkerHandler({ 69 | contextValues(req, env, ctx) { 70 | return createContextValues().set(userStore, { 71 | accessToken: "", 72 | }); 73 | }, 74 | interceptors: [anonymousInterceptor], 75 | routes(router) { 76 | router.service(ChatService, { 77 | listModels: async (req, ctx) => { 78 | const models = await env.AI.models({ 79 | task: "Text Generation", 80 | }); 81 | const response = create(ListModelsResponseSchema, { 82 | models: models.map((model) => ({ 83 | id: model.id, 84 | name: model.name, 85 | description: model.description, 86 | })), 87 | }); 88 | return response; 89 | }, 90 | listConversations: async (req, ctx) => { 91 | const accessToken = getUserAccessToken(ctx); 92 | const id: DurableObjectId = 93 | env.WORKERS_AI_DURABLE_OBJECT.idFromName(accessToken); 94 | const stub = env.WORKERS_AI_DURABLE_OBJECT.get(id); 95 | const conversations = await stub.listConversations(); 96 | const response = create(ListConversationsResponseSchema, { 97 | conversations: conversations.map((conversation) => ({ 98 | id: conversation.id, 99 | title: conversation.title ?? undefined, 100 | pinned: conversation.pinned ?? false, 101 | createdAt: conversation.created_at ?? undefined, 102 | updatedAt: conversation.updated_at ?? undefined, 103 | })), 104 | }); 105 | return response; 106 | }, 107 | createConversation: async (req, ctx) => { 108 | const accessToken = getUserAccessToken(ctx); 109 | const id: DurableObjectId = 110 | env.WORKERS_AI_DURABLE_OBJECT.idFromName(accessToken); 111 | const stub = env.WORKERS_AI_DURABLE_OBJECT.get(id); 112 | const conversation = await stub.createConversation(); 113 | const response = create(CreateConversationResponseSchema, { 114 | conversation: { 115 | id: conversation.id, 116 | title: conversation.title ?? undefined, 117 | pinned: conversation.pinned ?? false, 118 | createdAt: conversation.created_at ?? undefined, 119 | updatedAt: conversation.updated_at ?? undefined, 120 | }, 121 | }); 122 | return response; 123 | }, 124 | deleteConversation: async (req, ctx) => { 125 | const accessToken = getUserAccessToken(ctx); 126 | const id: DurableObjectId = 127 | env.WORKERS_AI_DURABLE_OBJECT.idFromName(accessToken); 128 | const stub = env.WORKERS_AI_DURABLE_OBJECT.get(id); 129 | await stub.deleteConversation(req.conversationId); 130 | return create(DeleteConversationResponseSchema, {}); 131 | }, 132 | renameConversation: async (req, ctx) => { 133 | const accessToken = getUserAccessToken(ctx); 134 | const id: DurableObjectId = 135 | env.WORKERS_AI_DURABLE_OBJECT.idFromName(accessToken); 136 | const stub = env.WORKERS_AI_DURABLE_OBJECT.get(id); 137 | await stub.renameConversation(req.conversationId, req.title); 138 | return create(RenameConversationResponseSchema, {}); 139 | }, 140 | pinConversation: async (req, ctx) => { 141 | const accessToken = getUserAccessToken(ctx); 142 | const id: DurableObjectId = 143 | env.WORKERS_AI_DURABLE_OBJECT.idFromName(accessToken); 144 | const stub = env.WORKERS_AI_DURABLE_OBJECT.get(id); 145 | await stub.pinConversation(req.conversationId); 146 | return create(PinConversationResponseSchema, {}); 147 | }, 148 | unpinConversation: async (req, ctx) => { 149 | const accessToken = getUserAccessToken(ctx); 150 | const id: DurableObjectId = 151 | env.WORKERS_AI_DURABLE_OBJECT.idFromName(accessToken); 152 | const stub = env.WORKERS_AI_DURABLE_OBJECT.get(id); 153 | await stub.unpinConversation(req.conversationId); 154 | return create(UnpinConversationResponseSchema, {}); 155 | }, 156 | listMessages: async (req, ctx) => { 157 | const accessToken = getUserAccessToken(ctx); 158 | const id: DurableObjectId = 159 | env.WORKERS_AI_DURABLE_OBJECT.idFromName(accessToken); 160 | const stub = env.WORKERS_AI_DURABLE_OBJECT.get(id); 161 | const messages = await stub.listMessages({ 162 | conversationId: req.conversationId, 163 | }); 164 | const response = create(ListMessagesResponseSchema, { 165 | messages: messages.map((message) => ({ 166 | id: message.id, 167 | conversationId: message.conversation_id, 168 | role: message.role, 169 | content: message.content, 170 | createdAt: message.created_at ?? undefined, 171 | })), 172 | }); 173 | return response; 174 | }, 175 | streamTTS: async function* (req, ctx) { 176 | try { 177 | const words = req.text.split(/\s+/).filter(Boolean); 178 | let chunks: string[]; 179 | 180 | if (words.length < TTS_SHORT_TEXT_WORD_THRESHOLD) { 181 | chunks = [req.text]; 182 | } else { 183 | const response = await env.AI.run( 184 | "@cf/meta/llama-4-scout-17b-16e-instruct" as unknown as any, 185 | { 186 | messages: [ 187 | { 188 | role: "system", 189 | content: getTTSChunkingPrompt(req.text), 190 | }, 191 | ], 192 | response_format: zodResponseFormat(TTSInputSchema, "tts_input"), 193 | }, 194 | { 195 | gateway: { 196 | id: env.CLOUDFLARE_AI_GATEWAY_ID, 197 | cacheTtl: 60 * 60 * 24 * 30, 198 | }, 199 | }, 200 | ); 201 | const result = response as unknown as { 202 | response: z.infer; 203 | }; 204 | chunks = result.response.chunks; 205 | } 206 | 207 | for (const chunk of chunks) { 208 | if (!chunk || chunk.trim().length === 0) { 209 | continue; 210 | } 211 | const ttsResponse = await env.AI.run( 212 | "@cf/myshell-ai/melotts", 213 | { 214 | prompt: chunk, 215 | }, 216 | { 217 | gateway: { 218 | id: env.CLOUDFLARE_AI_GATEWAY_ID, 219 | cacheTtl: 60 * 60 * 24 * 30, 220 | }, 221 | }, 222 | ); 223 | const audioData = ttsResponse.valueOf() as { 224 | audio: string; 225 | }; 226 | const data = base64ToBytes(audioData.audio); 227 | yield create(StreamTTSResponseSchema, { 228 | audio: data, 229 | }); 230 | } 231 | } catch (error) { 232 | console.error("Error in streamTTS:", error); 233 | } 234 | }, 235 | speechToText: async (req, ctx) => { 236 | const result = await env.AI.run("@cf/openai/whisper", { 237 | audio: bytesToNumberArray(req.audio), 238 | }); 239 | return create(SpeechToTextResponseSchema, { 240 | text: result.text, 241 | }); 242 | }, 243 | anonymousRegister: async (req, ctx) => { 244 | const accessToken = crypto.randomUUID(); 245 | const key = `anonymous_access_token:${accessToken}`; 246 | const existingToken = await env.KV.get(key); 247 | if (existingToken) { 248 | throw new ConnectError("Unauthorized", Code.Unauthenticated); 249 | } 250 | await env.KV.put(key, accessToken); 251 | return create(AnonymousRegisterResponseSchema, { 252 | accessToken, 253 | }); 254 | }, 255 | }); 256 | }, 257 | }); 258 | 259 | function base64ToBytes(base64: string) { 260 | const binString = atob(base64); 261 | return Uint8Array.from(binString, (m) => m.codePointAt(0) ?? 0); 262 | } 263 | 264 | function bytesToNumberArray(bytes: Uint8Array) { 265 | return Array.from(bytes).map((byte) => byte); 266 | } 267 | -------------------------------------------------------------------------------- /backend/src/connectrpc-handler.ts: -------------------------------------------------------------------------------- 1 | import { createConnectRouter } from "@connectrpc/connect"; 2 | import { 3 | universalServerRequestFromFetch, 4 | universalServerResponseToFetch, 5 | } from "@connectrpc/connect/protocol"; 6 | import type { 7 | ConnectRouter, 8 | ConnectRouterOptions, 9 | ContextValues, 10 | Interceptor, 11 | } from "@connectrpc/connect"; 12 | import type { UniversalHandler } from "@connectrpc/connect/protocol"; 13 | 14 | interface WokerHandlerOptions extends ConnectRouterOptions { 15 | routes: (router: ConnectRouter) => void; 16 | contextValues?: ( 17 | req: Request, 18 | env: Env, 19 | ctx: ExecutionContext, 20 | ) => ContextValues; 21 | notFound?: ( 22 | req: Request, 23 | env: Env, 24 | ctx: ExecutionContext, 25 | ) => Promise; 26 | interceptors?: Interceptor[]; 27 | } 28 | 29 | export function createWorkerHandler(options: WokerHandlerOptions) { 30 | const router = createConnectRouter({ 31 | interceptors: options.interceptors, 32 | }); 33 | options.routes(router); 34 | const paths = new Map(); 35 | for (const uHandler of router.handlers) { 36 | paths.set(uHandler.requestPath, uHandler); 37 | } 38 | return { 39 | async fetch(req: Request, env: Env, ctx: ExecutionContext) { 40 | const url = new URL(req.url); 41 | const handler = paths.get(url.pathname); 42 | if (handler === undefined) { 43 | return ( 44 | (await options?.notFound?.(req, env, ctx)) ?? 45 | new Response("Not found", { status: 404 }) 46 | ); 47 | } 48 | const uReq = { 49 | ...universalServerRequestFromFetch(req, {}), 50 | contextValues: options?.contextValues?.(req, env, ctx), 51 | }; 52 | const uRes = await handler(uReq); 53 | return universalServerResponseToFetch(uRes); 54 | }, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /backend/src/durable.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | import { 3 | drizzle, 4 | type DrizzleSqliteDODatabase, 5 | } from "drizzle-orm/durable-sqlite"; 6 | import { migrate } from "drizzle-orm/durable-sqlite/migrator"; 7 | import { OpenAI } from "openai"; 8 | import { z } from "zod"; 9 | import { eq } from "drizzle-orm"; 10 | import { zodResponseFormat } from "openai/helpers/zod"; 11 | import type { ChatCompletionTool } from "openai/resources/chat/completions"; 12 | 13 | const ConversationTitleExtraction = z.object({ 14 | title: z.string(), 15 | }); 16 | 17 | import migrations from "drizzle/migrations"; 18 | import * as schema from "drizzle/schema"; 19 | 20 | export type WebSocketChatStreamCreateMessage = { 21 | type: "chat.stream.create"; 22 | eventId: string; 23 | conversationId: string; 24 | content: string; 25 | model: string; 26 | tools: Array; 27 | }; 28 | 29 | export type WebSocketChatStreamCancelMessage = { 30 | type: "chat.stream.cancel"; 31 | eventId: string; 32 | conversationId: string; 33 | }; 34 | 35 | export type WebSocketChatRegenerateMessage = { 36 | type: "chat.regenerate"; 37 | eventId: string; 38 | conversationId: string; 39 | model: string; 40 | tools: Array; 41 | }; 42 | 43 | export type WebSocketStreamMessage = { 44 | type: "chat.stream.response"; 45 | eventId: string; 46 | conversationId: string; 47 | content: string; 48 | }; 49 | 50 | export type WebSocketStreamDoneMessage = { 51 | type: "chat.stream.done"; 52 | eventId: string; 53 | conversationId: string; 54 | function_call: { 55 | name: string; 56 | arguments: string; 57 | } | null; 58 | }; 59 | 60 | export type WebSocketConversationTitleMessage = { 61 | type: "conversation.title.update"; 62 | eventId: string; 63 | conversationId: string; 64 | title: string; 65 | }; 66 | 67 | export type WebSocketClientMessage = 68 | | WebSocketChatStreamCreateMessage 69 | | WebSocketChatStreamCancelMessage 70 | | WebSocketConversationTitleMessage 71 | | WebSocketChatRegenerateMessage; 72 | 73 | export type WebSocketServerMessage = 74 | | WebSocketStreamMessage 75 | | WebSocketStreamDoneMessage 76 | | WebSocketConversationTitleMessage; 77 | 78 | export class WorkersAIDurableObject extends DurableObject { 79 | db: DrizzleSqliteDODatabase; 80 | workersAI: OpenAI; 81 | abortController: AbortController; 82 | 83 | constructor(ctx: DurableObjectState, env: Env) { 84 | super(ctx, env); 85 | this.db = drizzle(ctx.storage, { schema, casing: "snake_case" }); 86 | this.workersAI = new OpenAI({ 87 | baseURL: `https://gateway.ai.cloudflare.com/v1/${env.CLOUDFLARE_ACCOUNT_ID}/${env.CLOUDFLARE_AI_GATEWAY_ID}/workers-ai/v1`, 88 | apiKey: env.CLOUDFLARE_WORKERS_AI_TOKEN, 89 | defaultHeaders: { 90 | "cf-aig-authorization": `Bearer ${env.CLOUDFLARE_AI_GATEWAY_TOKEN}`, 91 | }, 92 | }); 93 | this.abortController = new AbortController(); 94 | ctx.blockConcurrencyWhile(async () => { 95 | try { 96 | await migrate(this.db, migrations); 97 | } catch (error) { 98 | console.error(error); 99 | await migrate(this.db, migrations); 100 | } 101 | }); 102 | } 103 | 104 | async fetch(request: Request): Promise { 105 | const webSocketPair = new WebSocketPair(); 106 | const [client, server] = Object.values(webSocketPair); 107 | this.ctx.acceptWebSocket(server); 108 | return new Response(null, { 109 | status: 101, 110 | webSocket: client, 111 | }); 112 | } 113 | 114 | async webSocketMessage( 115 | ws: WebSocket, 116 | message: string | ArrayBuffer, 117 | ): Promise { 118 | const parsedMessage = JSON.parse( 119 | message as string, 120 | ) as WebSocketClientMessage; 121 | switch (parsedMessage.type) { 122 | case "chat.stream.create": 123 | await this.handleChat(ws, parsedMessage); 124 | break; 125 | case "chat.stream.cancel": 126 | this.abortController.abort(); 127 | break; 128 | case "chat.regenerate": 129 | await this.handleRegenerate(ws, parsedMessage); 130 | break; 131 | } 132 | } 133 | 134 | // workers ai doesn't support openai entrypoint tools 135 | private async handleChat( 136 | ws: WebSocket, 137 | parsedMessage: WebSocketChatStreamCreateMessage, 138 | ) { 139 | const { eventId, content, conversationId } = parsedMessage; 140 | this.abortController = new AbortController(); 141 | try { 142 | const conversation = await this.db.query.conversations.findFirst({ 143 | where(fields, operators) { 144 | return operators.eq(fields.id, conversationId); 145 | }, 146 | }); 147 | if (!conversation) { 148 | throw new Error(`Conversation not found: ${conversationId}`); 149 | } 150 | await this.db.insert(schema.messages).values({ 151 | user_id: "1", 152 | conversation_id: conversationId, 153 | role: "user", 154 | content, 155 | }); 156 | const messages = await this.db.query.messages.findMany({ 157 | columns: { 158 | role: true, 159 | content: true, 160 | }, 161 | where(fields, operators) { 162 | return operators.eq(fields.conversation_id, conversationId); 163 | }, 164 | orderBy(fields, operators) { 165 | return operators.asc(fields.created_at); 166 | }, 167 | }); 168 | const stream = await this.workersAI.chat.completions.create( 169 | { 170 | model: parsedMessage.model, 171 | messages, 172 | reasoning_effort: "low", 173 | stream: true, 174 | store: true, 175 | max_completion_tokens: 10000, 176 | }, 177 | { 178 | signal: this.abortController.signal, 179 | }, 180 | ); 181 | let response = ""; 182 | try { 183 | const toolCallMap = new Map< 184 | number, 185 | OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta.ToolCall 186 | >(); 187 | for await (const chunk of stream) { 188 | const delta = chunk.choices[0].delta; 189 | console.log(delta); 190 | let chunkContent = delta.content; 191 | if (chunkContent) { 192 | response += chunkContent; 193 | const streamMessage: WebSocketStreamMessage = { 194 | type: "chat.stream.response", 195 | eventId, 196 | conversationId, 197 | content: chunkContent, 198 | }; 199 | ws.send(JSON.stringify(streamMessage)); 200 | } 201 | for (const toolCall of delta.tool_calls ?? []) { 202 | const existingToolCall = toolCallMap.get(toolCall.index); 203 | if (existingToolCall) { 204 | if (toolCall.function?.name) { 205 | existingToolCall.function = existingToolCall.function || {}; 206 | existingToolCall.function.name = toolCall.function.name; 207 | } 208 | if (toolCall.function?.arguments) { 209 | existingToolCall.function = existingToolCall.function || {}; 210 | existingToolCall.function.arguments = 211 | (existingToolCall.function.arguments || "") + 212 | toolCall.function.arguments; 213 | } 214 | if (toolCall.id) { 215 | existingToolCall.id = toolCall.id; 216 | } 217 | if (toolCall.type) { 218 | existingToolCall.type = toolCall.type; 219 | } 220 | } else { 221 | toolCallMap.set(toolCall.index, { 222 | index: toolCall.index, 223 | id: toolCall.id, 224 | type: toolCall.type, 225 | function: { 226 | name: toolCall.function?.name, 227 | arguments: toolCall.function?.arguments || "", 228 | }, 229 | }); 230 | } 231 | } 232 | if (toolCallMap.size > 0) { 233 | const toolCall = Array.from(toolCallMap.values())[0]; 234 | const name = toolCall?.function?.name; 235 | if (name) { 236 | const doneMessage: WebSocketStreamDoneMessage = { 237 | type: "chat.stream.done", 238 | eventId, 239 | conversationId, 240 | function_call: { 241 | name, 242 | arguments: toolCall?.function?.arguments || "", 243 | }, 244 | }; 245 | ws.send(JSON.stringify(doneMessage)); 246 | } 247 | } 248 | } 249 | } catch (error: unknown) { 250 | if (error instanceof Error && error.name === "AbortError") { 251 | console.log(`Stream aborted for conversation ${conversationId}`); 252 | } else { 253 | console.error( 254 | `Error processing stream for conversation ${conversationId}:`, 255 | error, 256 | ); 257 | throw error; 258 | } 259 | } 260 | const doneMessage: WebSocketStreamDoneMessage = { 261 | type: "chat.stream.done", 262 | eventId, 263 | conversationId, 264 | function_call: null, 265 | }; 266 | ws.send(JSON.stringify(doneMessage)); 267 | await this.db.insert(schema.messages).values({ 268 | user_id: "1", 269 | conversation_id: conversationId, 270 | role: "assistant", 271 | content: response, 272 | }); 273 | await this.db 274 | .update(schema.conversations) 275 | .set({ 276 | updated_at: new Date().toISOString(), 277 | }) 278 | .where(eq(schema.conversations.id, conversationId)); 279 | if (conversation.title === null) { 280 | const messagesForTitle = [ 281 | ...messages, 282 | { role: "assistant" as const, content: response }, 283 | ]; 284 | try { 285 | const completion = await this.workersAI.chat.completions.create({ 286 | model: "@cf/meta/llama-4-scout-17b-16e-instruct", 287 | messages: [ 288 | { 289 | role: "system", 290 | content: 291 | 'You are a conversation title generator. Generate a concise, relevant title (max 1-2 sentences) for the following conversation, returning JSON like {"title": "Your Generated Title"}. Match the conversation\'s language.', 292 | }, 293 | ...messagesForTitle, 294 | ], 295 | response_format: zodResponseFormat( 296 | ConversationTitleExtraction, 297 | "conversation_title", 298 | ), 299 | max_completion_tokens: 50, 300 | }); 301 | const titleResponse = completion.choices[0].message; 302 | const parsedTitle = ConversationTitleExtraction.parse( 303 | JSON.parse(titleResponse.content || "{}"), 304 | ); 305 | if (parsedTitle.title) { 306 | await this.db 307 | .update(schema.conversations) 308 | .set({ title: parsedTitle.title }) 309 | .where(eq(schema.conversations.id, conversationId)); 310 | const titleMessage: WebSocketConversationTitleMessage = { 311 | type: "conversation.title.update", 312 | eventId, 313 | conversationId: conversationId, 314 | title: parsedTitle.title, 315 | }; 316 | ws.send(JSON.stringify(titleMessage)); 317 | } else { 318 | console.warn( 319 | `Generated empty title for conversation ${conversationId}`, 320 | ); 321 | } 322 | } catch (titleError) { 323 | console.error( 324 | `Failed to generate or parse title for conversation ${conversationId}:`, 325 | titleError, 326 | ); 327 | } 328 | } 329 | } catch (error) { 330 | console.error( 331 | `Error in handleChat for conversation ${conversationId}:`, 332 | error, 333 | ); 334 | try { 335 | ws.send( 336 | JSON.stringify({ 337 | type: "error", 338 | eventId, 339 | message: "An internal error occurred", 340 | }), 341 | ); 342 | } catch (wsError) { 343 | console.error( 344 | `Failed to send error message via WebSocket for conversation ${conversationId}:`, 345 | wsError, 346 | ); 347 | } 348 | } 349 | } 350 | 351 | private async handleRegenerate( 352 | ws: WebSocket, 353 | parsedMessage: WebSocketChatRegenerateMessage, 354 | ) { 355 | const { eventId, conversationId } = parsedMessage; 356 | this.abortController = new AbortController(); 357 | 358 | try { 359 | const allMessages = await this.db.query.messages.findMany({ 360 | columns: { 361 | id: true, 362 | role: true, 363 | content: true, 364 | }, 365 | where(fields, operators) { 366 | return operators.eq(fields.conversation_id, conversationId); 367 | }, 368 | orderBy(fields, operators) { 369 | return operators.asc(fields.created_at); 370 | }, 371 | }); 372 | 373 | if ( 374 | allMessages.length < 2 || 375 | allMessages[allMessages.length - 1].role !== "assistant" 376 | ) { 377 | console.warn( 378 | `Cannot regenerate for conversation ${conversationId}: No preceding assistant message found or history too short.`, 379 | ); 380 | ws.send( 381 | JSON.stringify({ 382 | type: "error", 383 | eventId, 384 | message: "Cannot regenerate the last message.", 385 | }), 386 | ); 387 | return; 388 | } 389 | 390 | const lastAssistantMessage = allMessages.pop()!; 391 | const lastAssistantMessageId = lastAssistantMessage.id; 392 | const messagesForRegeneration = allMessages.map(({ role, content }) => ({ 393 | role: role as "user" | "assistant", 394 | content, 395 | })); 396 | 397 | const stream = await this.workersAI.chat.completions.create( 398 | { 399 | model: parsedMessage.model, 400 | messages: messagesForRegeneration, 401 | stream: true, 402 | max_completion_tokens: 10000, 403 | reasoning_effort: "low", 404 | store: true, 405 | }, 406 | { 407 | signal: this.abortController.signal, 408 | headers: { 409 | "cf-aig-skip-cache": "true", 410 | }, 411 | }, 412 | ); 413 | 414 | let newResponse = ""; 415 | try { 416 | for await (const chunk of stream) { 417 | let chunkContent = chunk.choices?.[0]?.delta?.content; 418 | if (chunkContent) { 419 | newResponse += chunkContent; 420 | const streamMessage: WebSocketStreamMessage = { 421 | type: "chat.stream.response", 422 | eventId, 423 | conversationId, 424 | content: chunkContent, 425 | }; 426 | ws.send(JSON.stringify(streamMessage)); 427 | } 428 | } 429 | } catch (error: unknown) { 430 | if (error instanceof Error && error.name === "AbortError") { 431 | console.log( 432 | `Regeneration stream aborted for conversation ${conversationId}`, 433 | ); 434 | return; 435 | } else { 436 | console.error( 437 | `Error processing regeneration stream for conversation ${conversationId}:`, 438 | error, 439 | ); 440 | throw error; 441 | } 442 | } 443 | 444 | const doneMessage: WebSocketStreamDoneMessage = { 445 | type: "chat.stream.done", 446 | eventId, 447 | conversationId, 448 | function_call: null, 449 | }; 450 | ws.send(JSON.stringify(doneMessage)); 451 | 452 | await this.db 453 | .update(schema.messages) 454 | .set({ 455 | content: newResponse, 456 | }) 457 | .where(eq(schema.messages.id, lastAssistantMessageId)); 458 | 459 | await this.db 460 | .update(schema.conversations) 461 | .set({ updated_at: new Date().toISOString() }) 462 | .where(eq(schema.conversations.id, conversationId)); 463 | } catch (error) { 464 | console.error( 465 | `Error in handleRegenerate for conversation ${conversationId}:`, 466 | error, 467 | ); 468 | try { 469 | if (ws.readyState === WebSocket.OPEN) { 470 | ws.send( 471 | JSON.stringify({ 472 | type: "error", 473 | eventId, 474 | message: "An internal error occurred during regeneration.", 475 | }), 476 | ); 477 | } 478 | } catch (wsError) { 479 | console.error( 480 | `Failed to send error message via WebSocket for regeneration on conversation ${conversationId}:`, 481 | wsError, 482 | ); 483 | } 484 | } 485 | } 486 | 487 | async listConversations() { 488 | return await this.db.query.conversations.findMany({ 489 | orderBy(fields, operators) { 490 | return [ 491 | operators.desc(fields.pinned), 492 | operators.desc(fields.updated_at), 493 | ]; 494 | }, 495 | }); 496 | } 497 | 498 | async createConversation() { 499 | const [conversation] = await this.db 500 | .insert(schema.conversations) 501 | .values({ 502 | user_id: "1", 503 | }) 504 | .returning(); 505 | return conversation; 506 | } 507 | 508 | async deleteConversation(conversationId: string) { 509 | await this.db 510 | .delete(schema.conversations) 511 | .where(eq(schema.conversations.id, conversationId)); 512 | await this.db 513 | .delete(schema.messages) 514 | .where(eq(schema.messages.conversation_id, conversationId)); 515 | } 516 | 517 | async renameConversation(conversationId: string, title: string) { 518 | await this.db 519 | .update(schema.conversations) 520 | .set({ title }) 521 | .where(eq(schema.conversations.id, conversationId)); 522 | } 523 | 524 | async pinConversation(conversationId: string) { 525 | await this.db 526 | .update(schema.conversations) 527 | .set({ pinned: true }) 528 | .where(eq(schema.conversations.id, conversationId)); 529 | } 530 | 531 | async unpinConversation(conversationId: string) { 532 | await this.db 533 | .update(schema.conversations) 534 | .set({ pinned: false }) 535 | .where(eq(schema.conversations.id, conversationId)); 536 | } 537 | 538 | async listMessages({ 539 | conversationId, 540 | }: { 541 | conversationId: string; 542 | }) { 543 | return await this.db.query.messages.findMany({ 544 | where(fields, operators) { 545 | return operators.eq(fields.conversation_id, conversationId); 546 | }, 547 | orderBy(fields, operators) { 548 | return operators.asc(fields.created_at); 549 | }, 550 | }); 551 | } 552 | } 553 | -------------------------------------------------------------------------------- /backend/src/gen/chat/v1/chat_pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es v2.2.5 with parameter "target=ts" 2 | // @generated from file chat/v1/chat.proto (package chat.v1, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv1"; 6 | import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv1"; 7 | import type { Message as Message$1 } from "@bufbuild/protobuf"; 8 | 9 | /** 10 | * Describes the file chat/v1/chat.proto. 11 | */ 12 | export const file_chat_v1_chat: GenFile = /*@__PURE__*/ 13 | fileDesc("ChJjaGF0L3YxL2NoYXQucHJvdG8SB2NoYXQudjEiNgoFTW9kZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCSITChFMaXN0TW9kZWxzUmVxdWVzdCI0ChJMaXN0TW9kZWxzUmVzcG9uc2USHgoGbW9kZWxzGAEgAygLMg4uY2hhdC52MS5Nb2RlbCJhCgxDb252ZXJzYXRpb24SCgoCaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDgoGcGlubmVkGAMgASgIEhIKCmNyZWF0ZWRfYXQYBCABKAkSEgoKdXBkYXRlZF9hdBgFIAEoCSIaChhMaXN0Q29udmVyc2F0aW9uc1JlcXVlc3QiSQoZTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZRIsCg1jb252ZXJzYXRpb25zGAEgAygLMhUuY2hhdC52MS5Db252ZXJzYXRpb24iGwoZQ3JlYXRlQ29udmVyc2F0aW9uUmVxdWVzdCJJChpDcmVhdGVDb252ZXJzYXRpb25SZXNwb25zZRIrCgxjb252ZXJzYXRpb24YASABKAsyFS5jaGF0LnYxLkNvbnZlcnNhdGlvbiI0ChlEZWxldGVDb252ZXJzYXRpb25SZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSIcChpEZWxldGVDb252ZXJzYXRpb25SZXNwb25zZSJDChlSZW5hbWVDb252ZXJzYXRpb25SZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRINCgV0aXRsZRgCIAEoCSIcChpSZW5hbWVDb252ZXJzYXRpb25SZXNwb25zZSIxChZQaW5Db252ZXJzYXRpb25SZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSIZChdQaW5Db252ZXJzYXRpb25SZXNwb25zZSIzChhVbnBpbkNvbnZlcnNhdGlvblJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJIhsKGVVucGluQ29udmVyc2F0aW9uUmVzcG9uc2UiYQoHTWVzc2FnZRIKCgJpZBgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDAoEcm9sZRgDIAEoCRIPCgdjb250ZW50GAQgASgJEhIKCmNyZWF0ZWRfYXQYBSABKAkiLgoTTGlzdE1lc3NhZ2VzUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiOgoUTGlzdE1lc3NhZ2VzUmVzcG9uc2USIgoIbWVzc2FnZXMYASADKAsyEC5jaGF0LnYxLk1lc3NhZ2UiIAoQU3RyZWFtVFRTUmVxdWVzdBIMCgR0ZXh0GAEgASgJIiIKEVN0cmVhbVRUU1Jlc3BvbnNlEg0KBWF1ZGlvGAEgASgMIiQKE1NwZWVjaFRvVGV4dFJlcXVlc3QSDQoFYXVkaW8YASABKAwiJAoUU3BlZWNoVG9UZXh0UmVzcG9uc2USDAoEdGV4dBgBIAEoCSIaChhBbm9ueW1vdXNSZWdpc3RlclJlcXVlc3QiMQoZQW5vbnltb3VzUmVnaXN0ZXJSZXNwb25zZRIUCgxhY2Nlc3NfdG9rZW4YASABKAkyuwcKC0NoYXRTZXJ2aWNlEkUKCkxpc3RNb2RlbHMSGi5jaGF0LnYxLkxpc3RNb2RlbHNSZXF1ZXN0GhsuY2hhdC52MS5MaXN0TW9kZWxzUmVzcG9uc2USWgoRTGlzdENvbnZlcnNhdGlvbnMSIS5jaGF0LnYxLkxpc3RDb252ZXJzYXRpb25zUmVxdWVzdBoiLmNoYXQudjEuTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZRJdChJDcmVhdGVDb252ZXJzYXRpb24SIi5jaGF0LnYxLkNyZWF0ZUNvbnZlcnNhdGlvblJlcXVlc3QaIy5jaGF0LnYxLkNyZWF0ZUNvbnZlcnNhdGlvblJlc3BvbnNlEl0KEkRlbGV0ZUNvbnZlcnNhdGlvbhIiLmNoYXQudjEuRGVsZXRlQ29udmVyc2F0aW9uUmVxdWVzdBojLmNoYXQudjEuRGVsZXRlQ29udmVyc2F0aW9uUmVzcG9uc2USXQoSUmVuYW1lQ29udmVyc2F0aW9uEiIuY2hhdC52MS5SZW5hbWVDb252ZXJzYXRpb25SZXF1ZXN0GiMuY2hhdC52MS5SZW5hbWVDb252ZXJzYXRpb25SZXNwb25zZRJUCg9QaW5Db252ZXJzYXRpb24SHy5jaGF0LnYxLlBpbkNvbnZlcnNhdGlvblJlcXVlc3QaIC5jaGF0LnYxLlBpbkNvbnZlcnNhdGlvblJlc3BvbnNlEloKEVVucGluQ29udmVyc2F0aW9uEiEuY2hhdC52MS5VbnBpbkNvbnZlcnNhdGlvblJlcXVlc3QaIi5jaGF0LnYxLlVucGluQ29udmVyc2F0aW9uUmVzcG9uc2USSwoMTGlzdE1lc3NhZ2VzEhwuY2hhdC52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0Gh0uY2hhdC52MS5MaXN0TWVzc2FnZXNSZXNwb25zZRJECglTdHJlYW1UVFMSGS5jaGF0LnYxLlN0cmVhbVRUU1JlcXVlc3QaGi5jaGF0LnYxLlN0cmVhbVRUU1Jlc3BvbnNlMAESSwoMU3BlZWNoVG9UZXh0EhwuY2hhdC52MS5TcGVlY2hUb1RleHRSZXF1ZXN0Gh0uY2hhdC52MS5TcGVlY2hUb1RleHRSZXNwb25zZRJaChFBbm9ueW1vdXNSZWdpc3RlchIhLmNoYXQudjEuQW5vbnltb3VzUmVnaXN0ZXJSZXF1ZXN0GiIuY2hhdC52MS5Bbm9ueW1vdXNSZWdpc3RlclJlc3BvbnNlYgZwcm90bzM"); 14 | 15 | /** 16 | * @generated from message chat.v1.Model 17 | */ 18 | export type Model = Message$1<"chat.v1.Model"> & { 19 | /** 20 | * @generated from field: string id = 1; 21 | */ 22 | id: string; 23 | 24 | /** 25 | * @generated from field: string name = 2; 26 | */ 27 | name: string; 28 | 29 | /** 30 | * @generated from field: string description = 3; 31 | */ 32 | description: string; 33 | }; 34 | 35 | /** 36 | * Describes the message chat.v1.Model. 37 | * Use `create(ModelSchema)` to create a new message. 38 | */ 39 | export const ModelSchema: GenMessage = /*@__PURE__*/ 40 | messageDesc(file_chat_v1_chat, 0); 41 | 42 | /** 43 | * @generated from message chat.v1.ListModelsRequest 44 | */ 45 | export type ListModelsRequest = Message$1<"chat.v1.ListModelsRequest"> & { 46 | }; 47 | 48 | /** 49 | * Describes the message chat.v1.ListModelsRequest. 50 | * Use `create(ListModelsRequestSchema)` to create a new message. 51 | */ 52 | export const ListModelsRequestSchema: GenMessage = /*@__PURE__*/ 53 | messageDesc(file_chat_v1_chat, 1); 54 | 55 | /** 56 | * @generated from message chat.v1.ListModelsResponse 57 | */ 58 | export type ListModelsResponse = Message$1<"chat.v1.ListModelsResponse"> & { 59 | /** 60 | * @generated from field: repeated chat.v1.Model models = 1; 61 | */ 62 | models: Model[]; 63 | }; 64 | 65 | /** 66 | * Describes the message chat.v1.ListModelsResponse. 67 | * Use `create(ListModelsResponseSchema)` to create a new message. 68 | */ 69 | export const ListModelsResponseSchema: GenMessage = /*@__PURE__*/ 70 | messageDesc(file_chat_v1_chat, 2); 71 | 72 | /** 73 | * @generated from message chat.v1.Conversation 74 | */ 75 | export type Conversation = Message$1<"chat.v1.Conversation"> & { 76 | /** 77 | * @generated from field: string id = 1; 78 | */ 79 | id: string; 80 | 81 | /** 82 | * @generated from field: string title = 2; 83 | */ 84 | title: string; 85 | 86 | /** 87 | * @generated from field: bool pinned = 3; 88 | */ 89 | pinned: boolean; 90 | 91 | /** 92 | * @generated from field: string created_at = 4; 93 | */ 94 | createdAt: string; 95 | 96 | /** 97 | * @generated from field: string updated_at = 5; 98 | */ 99 | updatedAt: string; 100 | }; 101 | 102 | /** 103 | * Describes the message chat.v1.Conversation. 104 | * Use `create(ConversationSchema)` to create a new message. 105 | */ 106 | export const ConversationSchema: GenMessage = /*@__PURE__*/ 107 | messageDesc(file_chat_v1_chat, 3); 108 | 109 | /** 110 | * @generated from message chat.v1.ListConversationsRequest 111 | */ 112 | export type ListConversationsRequest = Message$1<"chat.v1.ListConversationsRequest"> & { 113 | }; 114 | 115 | /** 116 | * Describes the message chat.v1.ListConversationsRequest. 117 | * Use `create(ListConversationsRequestSchema)` to create a new message. 118 | */ 119 | export const ListConversationsRequestSchema: GenMessage = /*@__PURE__*/ 120 | messageDesc(file_chat_v1_chat, 4); 121 | 122 | /** 123 | * @generated from message chat.v1.ListConversationsResponse 124 | */ 125 | export type ListConversationsResponse = Message$1<"chat.v1.ListConversationsResponse"> & { 126 | /** 127 | * @generated from field: repeated chat.v1.Conversation conversations = 1; 128 | */ 129 | conversations: Conversation[]; 130 | }; 131 | 132 | /** 133 | * Describes the message chat.v1.ListConversationsResponse. 134 | * Use `create(ListConversationsResponseSchema)` to create a new message. 135 | */ 136 | export const ListConversationsResponseSchema: GenMessage = /*@__PURE__*/ 137 | messageDesc(file_chat_v1_chat, 5); 138 | 139 | /** 140 | * @generated from message chat.v1.CreateConversationRequest 141 | */ 142 | export type CreateConversationRequest = Message$1<"chat.v1.CreateConversationRequest"> & { 143 | }; 144 | 145 | /** 146 | * Describes the message chat.v1.CreateConversationRequest. 147 | * Use `create(CreateConversationRequestSchema)` to create a new message. 148 | */ 149 | export const CreateConversationRequestSchema: GenMessage = /*@__PURE__*/ 150 | messageDesc(file_chat_v1_chat, 6); 151 | 152 | /** 153 | * @generated from message chat.v1.CreateConversationResponse 154 | */ 155 | export type CreateConversationResponse = Message$1<"chat.v1.CreateConversationResponse"> & { 156 | /** 157 | * @generated from field: chat.v1.Conversation conversation = 1; 158 | */ 159 | conversation?: Conversation; 160 | }; 161 | 162 | /** 163 | * Describes the message chat.v1.CreateConversationResponse. 164 | * Use `create(CreateConversationResponseSchema)` to create a new message. 165 | */ 166 | export const CreateConversationResponseSchema: GenMessage = /*@__PURE__*/ 167 | messageDesc(file_chat_v1_chat, 7); 168 | 169 | /** 170 | * @generated from message chat.v1.DeleteConversationRequest 171 | */ 172 | export type DeleteConversationRequest = Message$1<"chat.v1.DeleteConversationRequest"> & { 173 | /** 174 | * @generated from field: string conversation_id = 1; 175 | */ 176 | conversationId: string; 177 | }; 178 | 179 | /** 180 | * Describes the message chat.v1.DeleteConversationRequest. 181 | * Use `create(DeleteConversationRequestSchema)` to create a new message. 182 | */ 183 | export const DeleteConversationRequestSchema: GenMessage = /*@__PURE__*/ 184 | messageDesc(file_chat_v1_chat, 8); 185 | 186 | /** 187 | * @generated from message chat.v1.DeleteConversationResponse 188 | */ 189 | export type DeleteConversationResponse = Message$1<"chat.v1.DeleteConversationResponse"> & { 190 | }; 191 | 192 | /** 193 | * Describes the message chat.v1.DeleteConversationResponse. 194 | * Use `create(DeleteConversationResponseSchema)` to create a new message. 195 | */ 196 | export const DeleteConversationResponseSchema: GenMessage = /*@__PURE__*/ 197 | messageDesc(file_chat_v1_chat, 9); 198 | 199 | /** 200 | * @generated from message chat.v1.RenameConversationRequest 201 | */ 202 | export type RenameConversationRequest = Message$1<"chat.v1.RenameConversationRequest"> & { 203 | /** 204 | * @generated from field: string conversation_id = 1; 205 | */ 206 | conversationId: string; 207 | 208 | /** 209 | * @generated from field: string title = 2; 210 | */ 211 | title: string; 212 | }; 213 | 214 | /** 215 | * Describes the message chat.v1.RenameConversationRequest. 216 | * Use `create(RenameConversationRequestSchema)` to create a new message. 217 | */ 218 | export const RenameConversationRequestSchema: GenMessage = /*@__PURE__*/ 219 | messageDesc(file_chat_v1_chat, 10); 220 | 221 | /** 222 | * @generated from message chat.v1.RenameConversationResponse 223 | */ 224 | export type RenameConversationResponse = Message$1<"chat.v1.RenameConversationResponse"> & { 225 | }; 226 | 227 | /** 228 | * Describes the message chat.v1.RenameConversationResponse. 229 | * Use `create(RenameConversationResponseSchema)` to create a new message. 230 | */ 231 | export const RenameConversationResponseSchema: GenMessage = /*@__PURE__*/ 232 | messageDesc(file_chat_v1_chat, 11); 233 | 234 | /** 235 | * @generated from message chat.v1.PinConversationRequest 236 | */ 237 | export type PinConversationRequest = Message$1<"chat.v1.PinConversationRequest"> & { 238 | /** 239 | * @generated from field: string conversation_id = 1; 240 | */ 241 | conversationId: string; 242 | }; 243 | 244 | /** 245 | * Describes the message chat.v1.PinConversationRequest. 246 | * Use `create(PinConversationRequestSchema)` to create a new message. 247 | */ 248 | export const PinConversationRequestSchema: GenMessage = /*@__PURE__*/ 249 | messageDesc(file_chat_v1_chat, 12); 250 | 251 | /** 252 | * @generated from message chat.v1.PinConversationResponse 253 | */ 254 | export type PinConversationResponse = Message$1<"chat.v1.PinConversationResponse"> & { 255 | }; 256 | 257 | /** 258 | * Describes the message chat.v1.PinConversationResponse. 259 | * Use `create(PinConversationResponseSchema)` to create a new message. 260 | */ 261 | export const PinConversationResponseSchema: GenMessage = /*@__PURE__*/ 262 | messageDesc(file_chat_v1_chat, 13); 263 | 264 | /** 265 | * @generated from message chat.v1.UnpinConversationRequest 266 | */ 267 | export type UnpinConversationRequest = Message$1<"chat.v1.UnpinConversationRequest"> & { 268 | /** 269 | * @generated from field: string conversation_id = 1; 270 | */ 271 | conversationId: string; 272 | }; 273 | 274 | /** 275 | * Describes the message chat.v1.UnpinConversationRequest. 276 | * Use `create(UnpinConversationRequestSchema)` to create a new message. 277 | */ 278 | export const UnpinConversationRequestSchema: GenMessage = /*@__PURE__*/ 279 | messageDesc(file_chat_v1_chat, 14); 280 | 281 | /** 282 | * @generated from message chat.v1.UnpinConversationResponse 283 | */ 284 | export type UnpinConversationResponse = Message$1<"chat.v1.UnpinConversationResponse"> & { 285 | }; 286 | 287 | /** 288 | * Describes the message chat.v1.UnpinConversationResponse. 289 | * Use `create(UnpinConversationResponseSchema)` to create a new message. 290 | */ 291 | export const UnpinConversationResponseSchema: GenMessage = /*@__PURE__*/ 292 | messageDesc(file_chat_v1_chat, 15); 293 | 294 | /** 295 | * @generated from message chat.v1.Message 296 | */ 297 | export type Message = Message$1<"chat.v1.Message"> & { 298 | /** 299 | * @generated from field: string id = 1; 300 | */ 301 | id: string; 302 | 303 | /** 304 | * @generated from field: string conversation_id = 2; 305 | */ 306 | conversationId: string; 307 | 308 | /** 309 | * @generated from field: string role = 3; 310 | */ 311 | role: string; 312 | 313 | /** 314 | * @generated from field: string content = 4; 315 | */ 316 | content: string; 317 | 318 | /** 319 | * @generated from field: string created_at = 5; 320 | */ 321 | createdAt: string; 322 | }; 323 | 324 | /** 325 | * Describes the message chat.v1.Message. 326 | * Use `create(MessageSchema)` to create a new message. 327 | */ 328 | export const MessageSchema: GenMessage = /*@__PURE__*/ 329 | messageDesc(file_chat_v1_chat, 16); 330 | 331 | /** 332 | * @generated from message chat.v1.ListMessagesRequest 333 | */ 334 | export type ListMessagesRequest = Message$1<"chat.v1.ListMessagesRequest"> & { 335 | /** 336 | * @generated from field: string conversation_id = 1; 337 | */ 338 | conversationId: string; 339 | }; 340 | 341 | /** 342 | * Describes the message chat.v1.ListMessagesRequest. 343 | * Use `create(ListMessagesRequestSchema)` to create a new message. 344 | */ 345 | export const ListMessagesRequestSchema: GenMessage = /*@__PURE__*/ 346 | messageDesc(file_chat_v1_chat, 17); 347 | 348 | /** 349 | * @generated from message chat.v1.ListMessagesResponse 350 | */ 351 | export type ListMessagesResponse = Message$1<"chat.v1.ListMessagesResponse"> & { 352 | /** 353 | * @generated from field: repeated chat.v1.Message messages = 1; 354 | */ 355 | messages: Message[]; 356 | }; 357 | 358 | /** 359 | * Describes the message chat.v1.ListMessagesResponse. 360 | * Use `create(ListMessagesResponseSchema)` to create a new message. 361 | */ 362 | export const ListMessagesResponseSchema: GenMessage = /*@__PURE__*/ 363 | messageDesc(file_chat_v1_chat, 18); 364 | 365 | /** 366 | * @generated from message chat.v1.StreamTTSRequest 367 | */ 368 | export type StreamTTSRequest = Message$1<"chat.v1.StreamTTSRequest"> & { 369 | /** 370 | * @generated from field: string text = 1; 371 | */ 372 | text: string; 373 | }; 374 | 375 | /** 376 | * Describes the message chat.v1.StreamTTSRequest. 377 | * Use `create(StreamTTSRequestSchema)` to create a new message. 378 | */ 379 | export const StreamTTSRequestSchema: GenMessage = /*@__PURE__*/ 380 | messageDesc(file_chat_v1_chat, 19); 381 | 382 | /** 383 | * @generated from message chat.v1.StreamTTSResponse 384 | */ 385 | export type StreamTTSResponse = Message$1<"chat.v1.StreamTTSResponse"> & { 386 | /** 387 | * @generated from field: bytes audio = 1; 388 | */ 389 | audio: Uint8Array; 390 | }; 391 | 392 | /** 393 | * Describes the message chat.v1.StreamTTSResponse. 394 | * Use `create(StreamTTSResponseSchema)` to create a new message. 395 | */ 396 | export const StreamTTSResponseSchema: GenMessage = /*@__PURE__*/ 397 | messageDesc(file_chat_v1_chat, 20); 398 | 399 | /** 400 | * @generated from message chat.v1.SpeechToTextRequest 401 | */ 402 | export type SpeechToTextRequest = Message$1<"chat.v1.SpeechToTextRequest"> & { 403 | /** 404 | * @generated from field: bytes audio = 1; 405 | */ 406 | audio: Uint8Array; 407 | }; 408 | 409 | /** 410 | * Describes the message chat.v1.SpeechToTextRequest. 411 | * Use `create(SpeechToTextRequestSchema)` to create a new message. 412 | */ 413 | export const SpeechToTextRequestSchema: GenMessage = /*@__PURE__*/ 414 | messageDesc(file_chat_v1_chat, 21); 415 | 416 | /** 417 | * @generated from message chat.v1.SpeechToTextResponse 418 | */ 419 | export type SpeechToTextResponse = Message$1<"chat.v1.SpeechToTextResponse"> & { 420 | /** 421 | * @generated from field: string text = 1; 422 | */ 423 | text: string; 424 | }; 425 | 426 | /** 427 | * Describes the message chat.v1.SpeechToTextResponse. 428 | * Use `create(SpeechToTextResponseSchema)` to create a new message. 429 | */ 430 | export const SpeechToTextResponseSchema: GenMessage = /*@__PURE__*/ 431 | messageDesc(file_chat_v1_chat, 22); 432 | 433 | /** 434 | * @generated from message chat.v1.AnonymousRegisterRequest 435 | */ 436 | export type AnonymousRegisterRequest = Message$1<"chat.v1.AnonymousRegisterRequest"> & { 437 | }; 438 | 439 | /** 440 | * Describes the message chat.v1.AnonymousRegisterRequest. 441 | * Use `create(AnonymousRegisterRequestSchema)` to create a new message. 442 | */ 443 | export const AnonymousRegisterRequestSchema: GenMessage = /*@__PURE__*/ 444 | messageDesc(file_chat_v1_chat, 23); 445 | 446 | /** 447 | * @generated from message chat.v1.AnonymousRegisterResponse 448 | */ 449 | export type AnonymousRegisterResponse = Message$1<"chat.v1.AnonymousRegisterResponse"> & { 450 | /** 451 | * @generated from field: string access_token = 1; 452 | */ 453 | accessToken: string; 454 | }; 455 | 456 | /** 457 | * Describes the message chat.v1.AnonymousRegisterResponse. 458 | * Use `create(AnonymousRegisterResponseSchema)` to create a new message. 459 | */ 460 | export const AnonymousRegisterResponseSchema: GenMessage = /*@__PURE__*/ 461 | messageDesc(file_chat_v1_chat, 24); 462 | 463 | /** 464 | * @generated from service chat.v1.ChatService 465 | */ 466 | export const ChatService: GenService<{ 467 | /** 468 | * @generated from rpc chat.v1.ChatService.ListModels 469 | */ 470 | listModels: { 471 | methodKind: "unary"; 472 | input: typeof ListModelsRequestSchema; 473 | output: typeof ListModelsResponseSchema; 474 | }, 475 | /** 476 | * @generated from rpc chat.v1.ChatService.ListConversations 477 | */ 478 | listConversations: { 479 | methodKind: "unary"; 480 | input: typeof ListConversationsRequestSchema; 481 | output: typeof ListConversationsResponseSchema; 482 | }, 483 | /** 484 | * @generated from rpc chat.v1.ChatService.CreateConversation 485 | */ 486 | createConversation: { 487 | methodKind: "unary"; 488 | input: typeof CreateConversationRequestSchema; 489 | output: typeof CreateConversationResponseSchema; 490 | }, 491 | /** 492 | * @generated from rpc chat.v1.ChatService.DeleteConversation 493 | */ 494 | deleteConversation: { 495 | methodKind: "unary"; 496 | input: typeof DeleteConversationRequestSchema; 497 | output: typeof DeleteConversationResponseSchema; 498 | }, 499 | /** 500 | * @generated from rpc chat.v1.ChatService.RenameConversation 501 | */ 502 | renameConversation: { 503 | methodKind: "unary"; 504 | input: typeof RenameConversationRequestSchema; 505 | output: typeof RenameConversationResponseSchema; 506 | }, 507 | /** 508 | * @generated from rpc chat.v1.ChatService.PinConversation 509 | */ 510 | pinConversation: { 511 | methodKind: "unary"; 512 | input: typeof PinConversationRequestSchema; 513 | output: typeof PinConversationResponseSchema; 514 | }, 515 | /** 516 | * @generated from rpc chat.v1.ChatService.UnpinConversation 517 | */ 518 | unpinConversation: { 519 | methodKind: "unary"; 520 | input: typeof UnpinConversationRequestSchema; 521 | output: typeof UnpinConversationResponseSchema; 522 | }, 523 | /** 524 | * @generated from rpc chat.v1.ChatService.ListMessages 525 | */ 526 | listMessages: { 527 | methodKind: "unary"; 528 | input: typeof ListMessagesRequestSchema; 529 | output: typeof ListMessagesResponseSchema; 530 | }, 531 | /** 532 | * @generated from rpc chat.v1.ChatService.StreamTTS 533 | */ 534 | streamTTS: { 535 | methodKind: "server_streaming"; 536 | input: typeof StreamTTSRequestSchema; 537 | output: typeof StreamTTSResponseSchema; 538 | }, 539 | /** 540 | * @generated from rpc chat.v1.ChatService.SpeechToText 541 | */ 542 | speechToText: { 543 | methodKind: "unary"; 544 | input: typeof SpeechToTextRequestSchema; 545 | output: typeof SpeechToTextResponseSchema; 546 | }, 547 | /** 548 | * @generated from rpc chat.v1.ChatService.AnonymousRegister 549 | */ 550 | anonymousRegister: { 551 | methodKind: "unary"; 552 | input: typeof AnonymousRegisterRequestSchema; 553 | output: typeof AnonymousRegisterResponseSchema; 554 | }, 555 | }> = /*@__PURE__*/ 556 | serviceDesc(file_chat_v1_chat, 0); 557 | 558 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | export { WorkersAIDurableObject } from "~/durable"; 2 | import { handler } from "~/connect"; 3 | 4 | export default { 5 | async fetch(request, env, ctx): Promise { 6 | const { pathname } = new URL(request.url); 7 | if (request.method === "GET" && pathname === "/websocket") { 8 | const upgradeHeader = request.headers.get("Upgrade"); 9 | if (upgradeHeader !== "websocket") { 10 | return new Response("Expected Upgrade: websocket", { 11 | status: 400, 12 | }); 13 | } 14 | const searchParams = new URL(request.url).searchParams; 15 | const accessToken = searchParams.get("accessToken"); 16 | if (!accessToken) { 17 | return new Response("Unauthorized", { status: 401 }); 18 | } 19 | const token = await env.KV.get(`anonymous_access_token:${accessToken}`); 20 | if (!token) { 21 | return new Response("Unauthorized", { status: 401 }); 22 | } 23 | const id: DurableObjectId = 24 | env.WORKERS_AI_DURABLE_OBJECT.idFromName(accessToken); 25 | const stub = env.WORKERS_AI_DURABLE_OBJECT.get(id); 26 | return stub.fetch(request); 27 | } 28 | const origin = request.headers.get("Origin"); 29 | if (request.method === "OPTIONS" && origin) { 30 | return new Response(null, { 31 | headers: { 32 | "Access-Control-Allow-Origin": origin ?? "*", 33 | "Access-Control-Allow-Methods": "GET, POST", 34 | "Access-Control-Allow-Headers": "*", 35 | "Access-Control-Max-Age": "86400", 36 | }, 37 | }); 38 | } 39 | const response = await handler.fetch(request, env, ctx); 40 | const corsHeaders = new Headers(response.headers); 41 | corsHeaders.set("Access-Control-Allow-Origin", origin ?? "*"); 42 | return new Response(response.body, { 43 | headers: corsHeaders, 44 | status: response.status, 45 | statusText: response.statusText, 46 | }); 47 | }, 48 | } satisfies ExportedHandler; 49 | -------------------------------------------------------------------------------- /backend/src/prompts/tts.ts: -------------------------------------------------------------------------------- 1 | export function getTTSChunkingPrompt(text: string): string { 2 | return `You are an assistant specializing in preparing text for text-to-speech (TTS). 3 | Please divide the following text into short, coherent chunks suitable for TTS processing. 4 | Ensure the chunks break at natural points like sentence or clause endings where possible. 5 | 6 | Text to chunk: 7 | ${text}`; 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/store-context.ts: -------------------------------------------------------------------------------- 1 | import { createContextKey } from "@connectrpc/connect"; 2 | 3 | type User = { 4 | accessToken: string; 5 | }; 6 | 7 | export const userStore = createContextKey(undefined); 8 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 6 | "target": "es2021", 7 | /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 8 | "lib": ["es2021"], 9 | /* Specify what JSX code is generated. */ 10 | "jsx": "react-jsx", 11 | 12 | /* Specify what module code is generated. */ 13 | "module": "es2022", 14 | /* Specify how TypeScript looks up a file from a given module specifier. */ 15 | "moduleResolution": "node", 16 | /* Specify type package names to be included without being referenced in a source file. */ 17 | "types": [], 18 | /* Enable importing .json files */ 19 | "resolveJsonModule": true, 20 | 21 | /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 22 | "allowJs": true, 23 | /* Enable error reporting in type-checked JavaScript files. */ 24 | "checkJs": false, 25 | 26 | /* Disable emitting files from a compilation. */ 27 | "noEmit": true, 28 | 29 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 30 | "isolatedModules": true, 31 | /* Allow 'import x from y' when a module doesn't have a default export. */ 32 | "allowSyntheticDefaultImports": true, 33 | /* Ensure that casing is correct in imports. */ 34 | "forceConsistentCasingInFileNames": true, 35 | 36 | /* Enable all strict type-checking options. */ 37 | "strict": true, 38 | 39 | /* Skip type checking all .d.ts files. */ 40 | "skipLibCheck": true, 41 | "baseUrl": ".", 42 | "paths": { 43 | "~/*": ["src/*"] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "workersai", 8 | "main": "src/index.ts", 9 | "compatibility_date": "2025-04-25", 10 | "migrations": [ 11 | { 12 | "new_sqlite_classes": ["WorkersAIDurableObject"], 13 | "tag": "v1" 14 | } 15 | ], 16 | "durable_objects": { 17 | "bindings": [ 18 | { 19 | "class_name": "WorkersAIDurableObject", 20 | "name": "WORKERS_AI_DURABLE_OBJECT" 21 | } 22 | ] 23 | }, 24 | "observability": { 25 | "enabled": true 26 | }, 27 | "ai": { 28 | "binding": "AI" 29 | }, 30 | "kv_namespaces": [ 31 | { 32 | "binding": "KV", 33 | "id": "3309a9541f08426eab63d9918bf8d65d" 34 | } 35 | ], 36 | "rules": [ 37 | { 38 | "type": "Text", 39 | "globs": ["**/*.sql"], 40 | "fallthrough": true 41 | } 42 | ], 43 | "assets": { 44 | "directory": "../frontend/build/client" 45 | } 46 | /** 47 | * Smart Placement 48 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 49 | */ 50 | // "placement": { "mode": "smart" }, 51 | 52 | /** 53 | * Bindings 54 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 55 | * databases, object storage, AI inference, real-time communication and more. 56 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 57 | */ 58 | 59 | /** 60 | * Environment Variables 61 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 62 | */ 63 | // "vars": { "MY_VARIABLE": "production_value" }, 64 | /** 65 | * Note: Use secrets to store sensitive data. 66 | * https://developers.cloudflare.com/workers/configuration/secrets/ 67 | */ 68 | 69 | /** 70 | * Static Assets 71 | * https://developers.cloudflare.com/workers/static-assets/binding/ 72 | */ 73 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 74 | 75 | /** 76 | * Service Bindings (communicate between multiple Workers) 77 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 78 | */ 79 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 80 | } 81 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | plugins: 3 | - local: protoc-gen-es 4 | out: backend/src/gen 5 | include_imports: true 6 | opt: target=ts 7 | - local: protoc-gen-es 8 | out: frontend/app/gen 9 | include_imports: true 10 | opt: target=ts -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | # For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml 2 | version: v2 3 | modules: 4 | - path: "proto" 5 | lint: 6 | use: 7 | - STANDARD 8 | breaking: 9 | use: 10 | - FILE 11 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | export VITE_API_URL=https://workersai.zwz.workers.dev/ # change to your own api url 2 | cd frontend 3 | pnpm format 4 | pnpm build 5 | cd ../backend 6 | pnpm format 7 | pnpm run deploy 8 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:8787 2 | VITE_WEBSOCKET_URL=ws://localhost:8787/websocket -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | 4 | # React Router 5 | /.react-router/ 6 | /build/ -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to React Router! 2 | 3 | A modern, production-ready template for building full-stack React applications using React Router. 4 | 5 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) 6 | 7 | ## Features 8 | 9 | - 🚀 Server-side rendering 10 | - ⚡️ Hot Module Replacement (HMR) 11 | - 📦 Asset bundling and optimization 12 | - 🔄 Data loading and mutations 13 | - 🔒 TypeScript by default 14 | - 🎉 TailwindCSS for styling 15 | - 📖 [React Router docs](https://reactrouter.com/) 16 | 17 | ## Getting Started 18 | 19 | ### Installation 20 | 21 | Install the dependencies: 22 | 23 | ```bash 24 | npm install 25 | ``` 26 | 27 | ### Development 28 | 29 | Start the development server with HMR: 30 | 31 | ```bash 32 | npm run dev 33 | ``` 34 | 35 | Your application will be available at `http://localhost:5173`. 36 | 37 | ## Building for Production 38 | 39 | Create a production build: 40 | 41 | ```bash 42 | npm run build 43 | ``` 44 | 45 | ## Deployment 46 | 47 | ### Docker Deployment 48 | 49 | To build and run using Docker: 50 | 51 | ```bash 52 | docker build -t my-app . 53 | 54 | # Run the container 55 | docker run -p 3000:3000 my-app 56 | ``` 57 | 58 | The containerized application can be deployed to any platform that supports Docker, including: 59 | 60 | - AWS ECS 61 | - Google Cloud Run 62 | - Azure Container Apps 63 | - Digital Ocean App Platform 64 | - Fly.io 65 | - Railway 66 | 67 | ### DIY Deployment 68 | 69 | If you're familiar with deploying Node applications, the built-in app server is production-ready. 70 | 71 | Make sure to deploy the output of `npm run build` 72 | 73 | ``` 74 | ├── package.json 75 | ├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) 76 | ├── build/ 77 | │ ├── client/ # Static assets 78 | │ └── server/ # Server-side code 79 | ``` 80 | 81 | ## Styling 82 | 83 | This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. 84 | 85 | --- 86 | 87 | Built with ❤️ using React Router. 88 | -------------------------------------------------------------------------------- /frontend/app/components/chat-bubble.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | HStack, 4 | Icon, 5 | IconButton, 6 | VStack, 7 | Clipboard, 8 | } from "@chakra-ui/react"; 9 | import { useEffect, useRef, useState } from "react"; 10 | import { 11 | RiVolumeMuteLine, 12 | RiVolumeUpLine, 13 | RiRefreshLine, 14 | } from "react-icons/ri"; 15 | import ReactMarkdown from "react-markdown"; 16 | 17 | import { Prose } from "~/components/ui/prose"; 18 | import { chatClient } from "~/connect"; 19 | import { WebSocketClient } from "~/lib/websocket"; 20 | import { useChatStore } from "~/stores/chat"; 21 | import type { Message } from "~/types"; 22 | import { removeQueryMessage, updateConversationUpdatedAt } from "~/utils/query"; 23 | 24 | interface ChatBubbleProps { 25 | message: Message; 26 | lastAssistantMessageId?: string; 27 | } 28 | 29 | export function StreamBubble() { 30 | return ( 31 | 32 | 41 | 42 | ); 43 | } 44 | 45 | export function StreamContentBubble({ 46 | streamContent, 47 | }: { 48 | streamContent: string; 49 | }) { 50 | return ( 51 | 52 | 53 | 58 | {streamContent} 59 | 60 | 69 | 70 | 71 | ); 72 | } 73 | 74 | export function ChatBubble({ 75 | message, 76 | lastAssistantMessageId, 77 | }: ChatBubbleProps) { 78 | switch (message.role) { 79 | case "user": 80 | return ; 81 | case "assistant": 82 | return ( 83 | 87 | ); 88 | } 89 | } 90 | 91 | export function UserBubble({ message }: ChatBubbleProps) { 92 | return ( 93 | 94 | 95 | {message.content} 96 | 97 | 98 | ); 99 | } 100 | 101 | export function RobotBubble({ 102 | message, 103 | lastAssistantMessageId, 104 | }: ChatBubbleProps) { 105 | const [isPlaying, setIsPlaying] = useState(false); 106 | const audioRef = useRef(null); 107 | const isPlayingRef = useRef(false); 108 | 109 | const { model, isStreaming, setIsStreaming } = useChatStore(); 110 | 111 | async function playAudio() { 112 | if (isPlayingRef.current) { 113 | stopAudio(); 114 | return; 115 | } 116 | setIsPlaying(true); 117 | isPlayingRef.current = true; 118 | try { 119 | const stream = chatClient.streamTTS({ 120 | text: message.content, 121 | }); 122 | for await (const chunk of stream) { 123 | if (!isPlayingRef.current) { 124 | console.log("Playback stopped externally (ref check)."); 125 | break; 126 | } 127 | const audio = new Audio(); 128 | audioRef.current = audio; 129 | const audioSrc = URL.createObjectURL(new Blob([chunk.audio])); 130 | audio.src = audioSrc; 131 | audio.play(); 132 | await new Promise((resolve) => (audio.onended = resolve)); 133 | URL.revokeObjectURL(audioSrc); 134 | audioRef.current = null; 135 | await new Promise((res) => setTimeout(res, 50)); 136 | } 137 | } catch (error) { 138 | console.error("Error during TTS playback:", error); 139 | } finally { 140 | isPlayingRef.current = false; 141 | setIsPlaying(false); 142 | if (audioRef.current) { 143 | audioRef.current.pause(); 144 | audioRef.current.src = ""; 145 | audioRef.current = null; 146 | } 147 | } 148 | } 149 | 150 | function stopAudio() { 151 | console.log("Stopping audio playback."); 152 | isPlayingRef.current = false; 153 | setIsPlaying(false); 154 | if (audioRef.current) { 155 | audioRef.current.pause(); 156 | audioRef.current.src = ""; 157 | audioRef.current = null; 158 | } 159 | } 160 | 161 | function regenerate() { 162 | removeQueryMessage(message); 163 | updateConversationUpdatedAt(message.conversationId); 164 | setIsStreaming(true); 165 | WebSocketClient.getInstance().send({ 166 | type: "chat.regenerate", 167 | eventId: message.id, 168 | conversationId: message.conversationId, 169 | model, 170 | }); 171 | } 172 | 173 | useEffect(() => { 174 | return () => { 175 | stopAudio(); 176 | }; 177 | }, []); 178 | 179 | return ( 180 | 181 | 182 | 183 | 188 | {message.content} 189 | 190 | 191 | 192 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 216 | 217 | 218 | 219 | ); 220 | } 221 | -------------------------------------------------------------------------------- /frontend/app/components/chat-input.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | VStack, 3 | HStack, 4 | Textarea, 5 | Spacer, 6 | IconButton, 7 | Icon, 8 | } from "@chakra-ui/react"; 9 | import { useState, useRef } from "react"; 10 | import { useMutation } from "@tanstack/react-query"; 11 | 12 | import { chatClient } from "~/connect"; 13 | import { RiArrowUpLine, RiMicLine, RiRecordCircleLine } from "react-icons/ri"; 14 | import { WebSocketClient } from "~/lib/websocket"; 15 | import { useChatStore } from "~/stores/chat"; 16 | import { 17 | addQueryConversation, 18 | addQueryMessage, 19 | updateConversationUpdatedAt, 20 | } from "~/utils/query"; 21 | import { toaster } from "~/components/ui/toaster"; 22 | import { stripProtoMetadata } from "~/types"; 23 | 24 | export function ChatInput() { 25 | const [isComposing, setIsComposing] = useState(false); 26 | const [content, setContent] = useState(""); 27 | const [isRecording, setIsRecording] = useState(false); 28 | const mediaRecorderRef = useRef(null); 29 | const audioChunksRef = useRef([]); 30 | 31 | const webSocket = WebSocketClient.getInstance(); 32 | const { conversationId, setConversationId, setIsStreaming } = useChatStore(); 33 | 34 | const createConversationMutation = useMutation({ 35 | mutationFn: async () => { 36 | const response = await chatClient.createConversation({}); 37 | return stripProtoMetadata(response.conversation); 38 | }, 39 | onSettled(data, error, variables, context) { 40 | if (data) { 41 | setConversationId(data.id); 42 | addQueryConversation(data); 43 | } 44 | }, 45 | }); 46 | 47 | const speechToTextMutation = useMutation({ 48 | mutationFn: async (audio: Uint8Array) => { 49 | const response = await chatClient.speechToText({ 50 | audio, 51 | }); 52 | return response.text; 53 | }, 54 | onSettled(data, error, variables, context) { 55 | if (data) { 56 | setContent((prev) => prev + data); 57 | } 58 | if (error) { 59 | toaster.error({ 60 | title: "Speech Recognition Error", 61 | description: "An error occurred during speech recognition.", 62 | }); 63 | } 64 | }, 65 | }); 66 | 67 | async function handleSendMessage() { 68 | if (content.trim() === "") return; 69 | let convId = conversationId; 70 | if (!convId) { 71 | const conversation = await createConversationMutation.mutateAsync(); 72 | if (conversation) { 73 | convId = conversation.id; 74 | } 75 | } 76 | if (!convId) return; 77 | updateConversationUpdatedAt(convId); 78 | setIsStreaming(true); 79 | const id = crypto.randomUUID(); 80 | addQueryMessage({ 81 | id, 82 | conversationId: convId, 83 | role: "user", 84 | content, 85 | createdAt: new Date().toISOString(), 86 | }); 87 | webSocket.send({ 88 | type: "chat.stream.create", 89 | eventId: id, 90 | conversationId: convId, 91 | content, 92 | model: useChatStore.getState().model, 93 | tools: [], 94 | }); 95 | setContent(""); 96 | } 97 | 98 | async function handleMicClick() { 99 | if (isRecording) { 100 | if (mediaRecorderRef.current) { 101 | mediaRecorderRef.current.stop(); 102 | } 103 | } else { 104 | try { 105 | const stream = await navigator.mediaDevices.getUserMedia({ 106 | audio: true, 107 | }); 108 | const recorder = new MediaRecorder(stream); 109 | mediaRecorderRef.current = recorder; 110 | audioChunksRef.current = []; 111 | 112 | recorder.ondataavailable = (event) => { 113 | if (event.data.size > 0) { 114 | audioChunksRef.current.push(event.data); 115 | } 116 | }; 117 | 118 | recorder.onstop = async () => { 119 | const audioBlob = new Blob(audioChunksRef.current, { 120 | type: "audio/webm", 121 | }); 122 | const audioBuffer = await audioBlob.arrayBuffer(); 123 | const audioUint8Array = new Uint8Array(audioBuffer); 124 | 125 | stream.getTracks().forEach((track) => track.stop()); 126 | setIsRecording(false); 127 | 128 | if (audioUint8Array.length === 0) { 129 | console.log("No audio recorded"); 130 | return; 131 | } 132 | speechToTextMutation.mutateAsync(audioUint8Array); 133 | }; 134 | recorder.start(); 135 | setIsRecording(true); 136 | } catch (error) { 137 | console.error("Error accessing microphone:", error); 138 | toaster.error({ 139 | title: "Microphone Access Denied", 140 | description: 141 | "Please allow microphone access in your browser settings.", 142 | }); 143 | setIsRecording(false); 144 | } 145 | } 146 | } 147 | 148 | return ( 149 | 150 | 159 | 160 |