feat(agent): migrate to Anthropic Agents SDK

Replace Vercel AI SDK with Anthropic Claude Agent SDK.
Add standalone agent server (packages/agent-server/)
with MCP tools, JWT auth, and SSE streaming. Introduce
bridge API routes (src/app/api/compass/) and custom
SSE hooks (use-agent, use-compass-chat) replacing
useChat. Remove provider.ts, tools.ts, system-prompt.ts,
github-tools.ts, usage.ts, and old agent route.
This commit is contained in:
Nicholai Vogel 2026-02-16 18:37:26 -07:00
parent 50c7d1d1e4
commit 7f5efb84e2
70 changed files with 11307 additions and 4253 deletions

1
.gitignore vendored
View File

@ -22,6 +22,7 @@ dist/
# misc # misc
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
*.png
# dev tools # dev tools
.playwright-mcp .playwright-mcp

View File

@ -89,6 +89,7 @@
"frappe-gantt": "^1.0.4", "frappe-gantt": "^1.0.4",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"isomorphic-dompurify": "^3.0.0-rc.2", "isomorphic-dompurify": "^3.0.0-rc.2",
"jose": "^6.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"marked": "^17.0.2", "marked": "^17.0.2",
"motion": "^12.33.0", "motion": "^12.33.0",
@ -1925,7 +1926,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@ -3401,7 +3402,7 @@
"@workos-inc/authkit-nextjs/@workos-inc/node": ["@workos-inc/node@7.82.0", "", { "dependencies": { "iron-session": "~6.3.1", "jose": "~5.6.3", "leb": "^1.0.0", "qs": "6.14.1" } }, "sha512-8h6XjIJf8nqNGYQMkWsjZ72WXMtzqrb4Azz39schXWoSRmwoK6tK+GpeAviJ9slddiJdOcp0Ht9/r+L6pGQMCg=="], "@workos-inc/authkit-nextjs/@workos-inc/node": ["@workos-inc/node@7.82.0", "", { "dependencies": { "iron-session": "~6.3.1", "jose": "~5.6.3", "leb": "^1.0.0", "qs": "6.14.1" } }, "sha512-8h6XjIJf8nqNGYQMkWsjZ72WXMtzqrb4Azz39schXWoSRmwoK6tK+GpeAviJ9slddiJdOcp0Ht9/r+L6pGQMCg=="],
"@workos-inc/node/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "@workos-inc/authkit-nextjs/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],

View File

@ -0,0 +1,10 @@
CREATE TABLE `user_provider_config` (
`user_id` text PRIMARY KEY NOT NULL,
`provider_type` text NOT NULL,
`api_key` text,
`base_url` text,
`model_overrides` text,
`is_active` integer DEFAULT 1 NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);

File diff suppressed because it is too large Load Diff

View File

@ -190,6 +190,13 @@
"when": 1771215013379, "when": 1771215013379,
"tag": "0026_easy_professor_monster", "tag": "0026_easy_professor_monster",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1771282232152,
"tag": "0027_flowery_hulk",
"breakpoints": true
} }
] ]
} }

View File

@ -32,7 +32,6 @@
"test:e2e:desktop": "TAURI=true playwright test --project=desktop-chromium" "test:e2e:desktop": "TAURI=true playwright test --project=desktop-chromium"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.74",
"@capacitor/android": "^8.0.2", "@capacitor/android": "^8.0.2",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/camera": "^8.0.0", "@capacitor/camera": "^8.0.0",
@ -59,7 +58,6 @@
"@json-render/core": "^0.4.0", "@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0", "@json-render/react": "^0.4.0",
"@opennextjs/cloudflare": "^1.14.4", "@opennextjs/cloudflare": "^1.14.4",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",
@ -103,7 +101,6 @@
"@workos-inc/authkit-nextjs": "^2.13.0", "@workos-inc/authkit-nextjs": "^2.13.0",
"@workos-inc/node": "^8.1.0", "@workos-inc/node": "^8.1.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^6.0.73",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -116,6 +113,7 @@
"frappe-gantt": "^1.0.4", "frappe-gantt": "^1.0.4",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"isomorphic-dompurify": "^3.0.0-rc.2", "isomorphic-dompurify": "^3.0.0-rc.2",
"jose": "^6.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"marked": "^17.0.2", "marked": "^17.0.2",
"motion": "^12.33.0", "motion": "^12.33.0",

View File

@ -0,0 +1,14 @@
# Required
AGENT_AUTH_SECRET=your-secret-key-here
# Optional - Anthropic API (can be overridden by user BYOK)
ANTHROPIC_API_KEY=sk-ant-...
# Optional - OpenRouter mode
# ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
# Optional - CORS configuration
ALLOWED_ORIGINS=http://localhost:3000
# Optional - Server port
PORT=3001

6
packages/agent-server/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
*.log
.DS_Store

View File

@ -0,0 +1,103 @@
# Agent Server
Standalone Node.js agent server wrapping the Anthropic Agent SDK for Compass.
## Overview
This server provides an SSE (Server-Sent Events) endpoint that streams AI agent responses using the Anthropic Agent SDK. It handles authentication, session management, and tool execution via MCP (Model Context Protocol) servers.
## Environment Variables
Required:
- `AGENT_AUTH_SECRET` - HS256 JWT signing secret for authentication
Optional:
- `ANTHROPIC_API_KEY` - Direct Anthropic API key (can be overridden by user BYOK)
- `ANTHROPIC_BASE_URL` - Set to OpenRouter URL for OpenRouter mode
- `ALLOWED_ORIGINS` - Comma-separated list of allowed CORS origins (default: `http://localhost:3000`)
- `PORT` - Server port (default: `3001`)
## API Endpoints
### POST /agent/chat
Stream AI agent responses via SSE.
**Headers:**
- `Authorization: Bearer <jwt>` - Required JWT token
- `x-session-id: <uuid>` - Session identifier (optional, auto-generated if missing)
- `x-current-page: <path>` - Current page path for context
- `x-timezone: <tz>` - User timezone
- `x-user-api-key: <key>` - User's own API key (BYOK, takes priority over server key)
**Request Body:**
```json
{
"messages": [
{ "role": "user", "content": "Hello" },
{ "role": "assistant", "content": "Hi there!" },
{ "role": "user", "content": "What can you do?" }
]
}
```
**Response:** SSE stream with the following event types:
```
data: {"type":"text","content":"..."}
data: {"type":"tool_use","name":"queryData","input":{...}}
data: {"type":"tool_result","name":"queryData","output":{...}}
data: {"type":"result","subtype":"success","result":"..."}
data: [DONE]
```
### GET /health
Health check endpoint.
**Response:**
```json
{
"status": "ok",
"version": "0.1.0"
}
```
## JWT Token Format
The JWT must be signed with HS256 and include the following claims:
```json
{
"userId": "user-uuid",
"orgId": "org-uuid",
"role": "admin|member|viewer",
"isDemoUser": false
}
```
## Running
Development:
```bash
bun run dev
```
Production:
```bash
bun run start
```
Build:
```bash
bun run build
```
## Architecture
- `src/index.ts` - HTTP server entry point (Bun.serve)
- `src/stream.ts` - Wraps SDK query() → SSE response
- `src/auth.ts` - JWT validation (HS256)
- `src/config.ts` - Environment configuration
- `src/sessions.ts` - In-memory session store
- `src/mcp/compass-server.ts` - MCP server for Compass tools

View File

@ -0,0 +1,71 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "agent-server",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.42",
"@anthropic-ai/sdk": "^0.74.0",
"jose": "^5.9.6",
"zod": "^3.24.1",
},
"devDependencies": {
"@types/bun": "latest",
},
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.42", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-/CugP7AjP57Dqtl2sbsDtxdbpQoPKIhjyF5WrTViGu4NHQdM+UikrRs4MhZ2jeotiC5R7iK9ZUN9SiBgcZ8oLw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
}
}

View File

@ -0,0 +1,23 @@
{
"name": "agent-server",
"version": "0.1.0",
"description": "Standalone Node.js agent server wrapping Anthropic Agent SDK",
"type": "module",
"bin": {
"agent-server": "./src/index.ts"
},
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun --hot run src/index.ts",
"build": "bun build src/index.ts --target=bun --outdir=dist --minify"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.42",
"@anthropic-ai/sdk": "^0.74.0",
"jose": "^5.9.6",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bun": "latest"
}
}

View File

@ -0,0 +1,61 @@
/**
* JWT authentication for agent server
* Validates tokens signed with HS256 and extracts user context
*/
import { jwtVerify } from "jose"
import { config } from "./config"
export interface ProviderConfig {
type: string // anthropic-oauth | anthropic-key | openrouter | ollama | custom
apiKey?: string
baseUrl?: string
modelOverrides?: Record<string, string> // { sonnet?: string, opus?: string, haiku?: string }
}
export interface AuthContext {
userId: string
orgId: string
role: string
isDemoUser: boolean
provider?: ProviderConfig
}
export async function validateAuth(
request: Request
): Promise<AuthContext | { error: string }> {
const authHeader = request.headers.get("authorization")
if (!authHeader?.startsWith("Bearer ")) {
return { error: "Missing or invalid Authorization header" }
}
const token = authHeader.slice(7)
try {
const secret = new TextEncoder().encode(config.agentAuthSecret)
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
})
// Extract claims from JWT payload
const userId = (payload.sub ?? payload.userId) as string | undefined
const orgId = payload.orgId as string | undefined
const role = payload.role as string | undefined
const isDemoUser = payload.isDemoUser as boolean | undefined
const provider = payload.provider as ProviderConfig | undefined
if (!userId || !orgId || !role) {
return { error: "Invalid token payload: missing required claims" }
}
return {
userId,
orgId,
role,
isDemoUser: isDemoUser ?? false,
provider,
}
} catch (err) {
return { error: `JWT verification failed: ${err}` }
}
}

View File

@ -0,0 +1,43 @@
/**
* Configuration for agent server
* Reads from environment variables
*/
export interface Config {
anthropicApiKey: string | undefined
anthropicBaseUrl: string | undefined
compassApiBaseUrl: string
agentAuthSecret: string
allowedOrigins: string[]
port: number
}
function parseOrigins(originsStr: string | undefined): string[] {
if (!originsStr) {
return ["http://localhost:3000"]
}
return originsStr.split(",").map(o => o.trim())
}
export function loadConfig(): Config {
const agentAuthSecret = process.env.AGENT_AUTH_SECRET
if (!agentAuthSecret) {
throw new Error("AGENT_AUTH_SECRET environment variable is required")
}
const compassApiBaseUrl = process.env.COMPASS_API_BASE_URL
if (!compassApiBaseUrl) {
throw new Error("COMPASS_API_BASE_URL environment variable is required")
}
return {
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL,
compassApiBaseUrl,
agentAuthSecret,
allowedOrigins: parseOrigins(process.env.ALLOWED_ORIGINS),
port: parseInt(process.env.PORT || "3001", 10),
}
}
export const config = loadConfig()

View File

@ -0,0 +1,155 @@
#!/usr/bin/env bun
/**
* Compass Agent Server
* Standalone Node.js server wrapping Anthropic Agent SDK
*/
import { config } from "./config"
import { validateAuth } from "./auth"
import { getOrCreateSession } from "./sessions"
import { createAgentStream } from "./stream"
interface ChatRequest {
messages: Array<{
role: "user" | "assistant"
content: string
}>
}
/**
* Handle POST /agent/chat
*/
async function handleChat(request: Request): Promise<Response> {
// Validate auth
const authResult = await validateAuth(request)
if ("error" in authResult) {
return new Response(
JSON.stringify({ error: authResult.error }),
{ status: 401, headers: { "Content-Type": "application/json" } }
)
}
// Extract JWT token from Authorization header
const authHeader = request.headers.get("authorization")
const authToken = authHeader?.slice(7) || "" // Remove "Bearer " prefix
// Extract headers
const sessionId = request.headers.get("x-session-id") || crypto.randomUUID()
const currentPage = request.headers.get("x-current-page") || "/"
const timezone = request.headers.get("x-timezone") || "UTC"
const model = request.headers.get("x-model") || "sonnet"
// Get or create session
getOrCreateSession(sessionId)
// Parse body
let body: ChatRequest
try {
body = await request.json()
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
if (!Array.isArray(body.messages) || body.messages.length === 0) {
return new Response(
JSON.stringify({ error: "messages array is required and cannot be empty" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
// Create SSE stream
const stream = await createAgentStream(body.messages, {
auth: authResult,
authToken,
sessionId,
currentPage,
timezone,
provider: authResult.provider,
model,
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": request.headers.get("origin") || "*",
"Access-Control-Allow-Credentials": "true",
},
})
}
/**
* Handle GET /health
*/
function handleHealth(): Response {
return new Response(
JSON.stringify({ status: "ok", version: "0.1.0" }),
{ headers: { "Content-Type": "application/json" } }
)
}
/**
* CORS preflight handler
*/
function handlePreflight(request: Request): Response {
const origin = request.headers.get("origin") || ""
const allowed = config.allowedOrigins.includes(origin) || config.allowedOrigins.includes("*")
if (!allowed) {
return new Response(null, { status: 403 })
}
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, x-session-id, x-current-page, x-timezone, x-model",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400",
},
})
}
/**
* Main request router
*/
async function handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url)
// Handle CORS preflight
if (request.method === "OPTIONS") {
return handlePreflight(request)
}
// Route requests
if (url.pathname === "/health" && request.method === "GET") {
return handleHealth()
}
if (url.pathname === "/agent/chat" && request.method === "POST") {
return handleChat(request)
}
return new Response(
JSON.stringify({ error: "Not found" }),
{ status: 404, headers: { "Content-Type": "application/json" } }
)
}
/**
* Start the server
*/
const server = Bun.serve({
port: config.port,
async fetch(request) {
return handleRequest(request)
},
})
console.log(`Agent server running on http://localhost:${server.port}`)
console.log(`Allowed origins: ${config.allowedOrigins.join(", ")}`)

View File

@ -0,0 +1,27 @@
/**
* Shared HTTP client for calling the Compass Workers API.
* All MCP tools use this to interact with the Compass backend.
*/
export async function compassApi<T>(
baseUrl: string,
path: string,
authToken: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${baseUrl}${path}`, {
method: body ? "POST" : "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${authToken}`,
},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error ?? `API error ${res.status}`)
}
return res.json() as Promise<T>
}

View File

@ -0,0 +1,33 @@
import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"
import { dataTools } from "./data-tools"
import { uiTools } from "./ui-tools"
import { scheduleTools } from "./schedule-tools"
import { themeTools } from "./theme-tools"
import { memoryTools } from "./memory-tools"
import { skillTools } from "./skill-tools"
import { githubTools } from "./github-tools"
import { dashboardTools } from "./dashboard-tools"
/**
* Create the Compass MCP server with all domain tools.
*
* @param apiBaseUrl - Base URL for the Compass Workers API (e.g., "https://compass.example.com")
* @param authToken - JWT auth token for API requests
* @returns MCP server instance
*/
export function createCompassMcpServer(apiBaseUrl: string, authToken: string) {
return createSdkMcpServer({
name: "compass",
version: "1.0.0",
tools: [
...dataTools(apiBaseUrl, authToken),
...uiTools(),
...scheduleTools(apiBaseUrl, authToken),
...themeTools(apiBaseUrl, authToken),
...memoryTools(apiBaseUrl, authToken),
...skillTools(apiBaseUrl, authToken),
...githubTools(apiBaseUrl, authToken),
...dashboardTools(apiBaseUrl, authToken),
]
})
}

View File

@ -0,0 +1,97 @@
import { tool } from "@anthropic-ai/claude-agent-sdk"
import { z } from "zod"
import { compassApi } from "./api-client"
export function dashboardTools(apiBaseUrl: string, authToken: string) {
return [
tool(
"saveDashboard",
"Save the currently rendered UI as a named dashboard. The client captures the current spec and data context automatically. Returns an action for the client to handle the save.",
{
name: z.string().describe("Dashboard display name"),
description: z.string().optional().describe(
"Brief description of the dashboard"
),
dashboardId: z.string().optional().describe(
"Existing dashboard ID to update (for edits)"
),
},
async (args) => {
return {
content: [{
type: "text",
text: JSON.stringify({
action: "save_dashboard",
name: args.name,
description: args.description ?? "",
dashboardId: args.dashboardId,
})
}]
}
}
),
tool(
"listDashboards",
"List the user's saved custom dashboards.",
{},
async () => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/dashboards/list",
authToken
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"editDashboard",
"Load a saved dashboard for editing. The client injects the spec into the render context and navigates to /dashboard. Optionally pass an editPrompt to trigger immediate re-generation.",
{
dashboardId: z.string().describe("ID of the dashboard to edit"),
editPrompt: z.string().optional().describe("Description of changes to make"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/dashboards/get",
authToken,
{ dashboardId: args.dashboardId }
)
return {
content: [{
type: "text",
text: JSON.stringify({
action: "load_dashboard",
dashboardId: args.dashboardId,
spec: result,
editPrompt: args.editPrompt,
})
}]
}
}
),
tool(
"deleteDashboard",
"Delete a saved dashboard. Always confirm with the user before deleting.",
{
dashboardId: z.string().describe("ID of the dashboard to delete"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/dashboards/delete",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
)
]
}

View File

@ -0,0 +1,39 @@
import { tool } from "@anthropic-ai/claude-agent-sdk"
import { z } from "zod"
import { compassApi } from "./api-client"
export function dataTools(apiBaseUrl: string, authToken: string) {
return [
tool(
"queryData",
"Query the application database. Describe what data you need in natural language and provide a query type.",
{
queryType: z.enum([
"customers",
"vendors",
"projects",
"invoices",
"vendor_bills",
"schedule_tasks",
"project_detail",
"customer_detail",
"vendor_detail",
]),
id: z.string().optional().describe("Record ID for detail queries"),
search: z.string().optional().describe("Search term to filter results"),
limit: z.number().optional().describe("Max results to return (default 20)"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/query",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
)
]
}

View File

@ -0,0 +1,98 @@
import { tool } from "@anthropic-ai/claude-agent-sdk"
import { z } from "zod"
import { compassApi } from "./api-client"
export function githubTools(apiBaseUrl: string, authToken: string) {
return [
tool(
"queryGitHub",
"Query GitHub repository data: commits, pull requests, issues, contributors, milestones, or repo stats.",
{
queryType: z.enum([
"commits",
"commit_diff",
"pull_requests",
"issues",
"contributors",
"milestones",
"repo_stats",
]).describe("Type of GitHub data to query"),
sha: z.string().optional().describe("Commit SHA for commit_diff queries"),
state: z.enum(["open", "closed", "all"]).optional().describe(
"State filter for PRs, issues, milestones"
),
labels: z.string().optional().describe("Comma-separated labels to filter issues"),
limit: z.number().optional().describe("Max results to return (default 10)"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/github/query",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"createGitHubIssue",
"Create a new GitHub issue in the Compass repository. Always confirm with the user before creating.",
{
title: z.string().describe("Issue title"),
body: z.string().describe("Issue body in markdown"),
labels: z.array(z.string()).optional().describe("Labels to apply"),
assignee: z.string().optional().describe("GitHub username to assign"),
milestone: z.number().optional().describe("Milestone number to attach to"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/github/create-issue",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"saveInterviewFeedback",
"Save the results of a UX interview. Call this after completing an interview with the user. Saves to the database and creates a GitHub issue tagged user-feedback.",
{
responses: z.array(
z.object({
question: z.string(),
answer: z.string(),
})
).describe("Array of question/answer pairs from the interview"),
summary: z.string().describe("Brief summary of the interview findings"),
painPoints: z.array(z.string()).optional().describe("Key pain points identified"),
featureRequests: z.array(z.string()).optional().describe(
"Feature requests from the user"
),
overallSentiment: z.enum([
"positive",
"neutral",
"negative",
"mixed"
]).describe("Overall sentiment of the feedback"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/github/save-interview",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
)
]
}

View File

@ -0,0 +1,14 @@
/**
* MCP server exports
*/
export { createCompassMcpServer } from "./compass-server"
export { compassApi } from "./api-client"
export { dataTools } from "./data-tools"
export { uiTools } from "./ui-tools"
export { scheduleTools } from "./schedule-tools"
export { themeTools } from "./theme-tools"
export { memoryTools } from "./memory-tools"
export { skillTools } from "./skill-tools"
export { githubTools } from "./github-tools"
export { dashboardTools } from "./dashboard-tools"

View File

@ -0,0 +1,58 @@
import { tool } from "@anthropic-ai/claude-agent-sdk"
import { z } from "zod"
import { compassApi } from "./api-client"
export function memoryTools(apiBaseUrl: string, authToken: string) {
return [
tool(
"rememberContext",
"Save something to persistent memory. Use when the user shares a preference, makes a decision, or mentions a fact worth remembering across sessions.",
{
content: z.string().describe(
"What to remember (a preference, decision, fact, or workflow)"
),
memoryType: z.enum([
"preference",
"workflow",
"fact",
"decision",
]).describe("Category of memory"),
tags: z.string().optional().describe("Comma-separated tags for categorization"),
importance: z.number().min(0.3).max(1.0).optional().describe(
"Importance weight 0.3-1.0 (default 0.7)"
),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/memory/save",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"recallMemory",
"Search persistent memories for this user. Use when the user asks if you remember something or when you need to look up a past preference or decision.",
{
query: z.string().describe("What to search for in memories"),
limit: z.number().optional().describe("Max results (default 5)"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/memory/recall",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
)
]
}

View File

@ -0,0 +1,188 @@
import { tool } from "@anthropic-ai/claude-agent-sdk"
import { z } from "zod"
import { compassApi } from "./api-client"
export function scheduleTools(apiBaseUrl: string, authToken: string) {
return [
tool(
"getProjectSchedule",
"Get the full schedule for a project including tasks, dependencies, workday exceptions, and a computed summary (counts, overall %, critical path). Always call this before making schedule mutations to resolve task names to IDs.",
{
projectId: z.string().describe("The project UUID"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/schedule/get",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"createScheduleTask",
"Create a new task on a project schedule. Returns a toast confirmation. Dates are ISO format (YYYY-MM-DD).",
{
projectId: z.string().describe("The project UUID"),
title: z.string().describe("Task title"),
startDate: z.string().describe("Start date in YYYY-MM-DD format"),
workdays: z.number().describe("Duration in working days"),
phase: z.string().describe(
"Construction phase (preconstruction, sitework, foundation, framing, roofing, electrical, plumbing, hvac, insulation, drywall, finish, landscaping, closeout)"
),
isMilestone: z.boolean().optional().describe("Whether this is a milestone (0 workdays)"),
percentComplete: z.number().min(0).max(100).optional().describe("Initial percent complete (0-100)"),
assignedTo: z.string().optional().describe("Name of the person assigned"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/schedule/create",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"updateScheduleTask",
"Update an existing schedule task. Provide only the fields to change. Use getProjectSchedule first to resolve task names to IDs.",
{
taskId: z.string().describe("The task UUID"),
title: z.string().optional().describe("New title"),
startDate: z.string().optional().describe("New start date (YYYY-MM-DD)"),
workdays: z.number().optional().describe("New duration in working days"),
phase: z.string().optional().describe("New phase"),
status: z.enum(["PENDING", "IN_PROGRESS", "COMPLETE", "BLOCKED"]).optional().describe("New status"),
isMilestone: z.boolean().optional().describe("Set milestone flag"),
percentComplete: z.number().min(0).max(100).optional().describe("New percent complete (0-100)"),
assignedTo: z.string().nullable().optional().describe("New assignee (null to unassign)"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/schedule/update",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"deleteScheduleTask",
"Delete a schedule task. Always confirm with the user before deleting. This also removes any dependencies involving the task.",
{
taskId: z.string().describe("The task UUID to delete"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/schedule/delete",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"createScheduleDependency",
"Create a dependency between two tasks. Has built-in cycle detection. Use getProjectSchedule first to resolve task names to IDs.",
{
projectId: z.string().describe("The project UUID"),
predecessorId: z.string().describe("UUID of the predecessor task"),
successorId: z.string().describe("UUID of the successor task"),
type: z.enum(["FS", "SS", "FF", "SF"]).describe(
"Dependency type: FS (finish-to-start), SS (start-to-start), FF (finish-to-finish), SF (start-to-finish)"
),
lagDays: z.number().optional().describe("Lag in working days (default 0)"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/schedule/create-dependency",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"deleteScheduleDependency",
"Delete a dependency between tasks. Use getProjectSchedule first to find the dependency ID.",
{
dependencyId: z.string().describe("The dependency UUID to delete"),
projectId: z.string().describe("The project UUID (for revalidation)"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/schedule/delete-dependency",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"addWorkdayException",
"Add a workday exception to a project (holiday, non-working day, or extra working day).",
{
projectId: z.string().describe("The project UUID"),
date: z.string().describe("Exception date in YYYY-MM-DD format"),
category: z.enum(["holiday", "non_working", "extra_working"]).describe("Exception category"),
recurrence: z.enum(["none", "annual", "weekly"]).optional().describe("Recurrence pattern (default none)"),
description: z.string().optional().describe("Description of the exception"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/schedule/add-exception",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"removeWorkdayException",
"Remove a workday exception from a project.",
{
exceptionId: z.string().describe("The exception UUID to remove"),
projectId: z.string().describe("The project UUID"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/schedule/remove-exception",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
)
]
}

View File

@ -0,0 +1,83 @@
import { tool } from "@anthropic-ai/claude-agent-sdk"
import { z } from "zod"
import { compassApi } from "./api-client"
export function skillTools(apiBaseUrl: string, authToken: string) {
return [
tool(
"installSkill",
"Install a skill from GitHub (skills.sh format). Source format: owner/repo or owner/repo/skill-name. Requires admin role. Always confirm with the user what skill they want before installing.",
{
source: z.string().describe(
"GitHub source path, e.g. 'cloudflare/skills/wrangler'"
),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/skills/install",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"listInstalledSkills",
"List all installed agent skills with their status.",
{},
async () => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/skills/list",
authToken
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"toggleInstalledSkill",
"Enable or disable an installed skill.",
{
pluginId: z.string().describe("The plugin ID of the skill"),
enabled: z.boolean().describe("true to enable, false to disable"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/skills/toggle",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"uninstallSkill",
"Remove an installed skill permanently. Requires admin role. Always confirm before uninstalling.",
{
pluginId: z.string().describe("The plugin ID of the skill"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/skills/uninstall",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
)
]
}

View File

@ -0,0 +1,113 @@
import { tool } from "@anthropic-ai/claude-agent-sdk"
import { z } from "zod"
import { compassApi } from "./api-client"
export function themeTools(apiBaseUrl: string, authToken: string) {
return [
tool(
"listThemes",
"List available visual themes (presets + user custom themes).",
{},
async () => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/themes/list",
authToken
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"setTheme",
"Switch the user's visual theme. Use a preset ID (native-compass, corpo, notebook, doom-64, bubblegum, developers-choice, anslopics-clood, violet-bloom, soy, mocha) or a custom theme UUID.",
{
themeId: z.string().describe("The theme ID to activate"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/themes/set",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"generateTheme",
"Generate and save a custom visual theme. Provide complete light and dark color maps (all 32 keys), fonts, optional Google Font names, and design tokens. All colors must be in oklch() format.",
{
name: z.string().describe("Theme display name"),
description: z.string().describe("Brief theme description"),
light: z.record(z.string(), z.string()).describe(
"Light mode color map with all 32 ThemeColorKey entries"
),
dark: z.record(z.string(), z.string()).describe(
"Dark mode color map with all 32 ThemeColorKey entries"
),
fonts: z.object({
sans: z.string(),
serif: z.string(),
mono: z.string(),
}).describe("CSS font-family strings"),
googleFonts: z.array(z.string()).optional().describe(
"Google Font names to load (case-sensitive)"
),
radius: z.string().optional().describe("Border radius (e.g. '0.5rem')"),
spacing: z.string().optional().describe("Base spacing (e.g. '0.25rem')"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/themes/generate",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
),
tool(
"editTheme",
"Edit an existing custom theme. Provide the theme ID and only the properties you want to change. Unspecified properties are preserved from the existing theme. Only works on custom themes (not presets).",
{
themeId: z.string().describe("ID of existing custom theme to edit"),
name: z.string().optional().describe("New display name"),
description: z.string().optional().describe("New description"),
light: z.record(z.string(), z.string()).optional().describe(
"Partial light color overrides (only changed keys)"
),
dark: z.record(z.string(), z.string()).optional().describe(
"Partial dark color overrides (only changed keys)"
),
fonts: z.object({
sans: z.string().optional(),
serif: z.string().optional(),
mono: z.string().optional(),
}).optional().describe("Partial font overrides"),
googleFonts: z.array(z.string()).optional().describe("Replace Google Font list"),
radius: z.string().optional().describe("New border radius"),
spacing: z.string().optional().describe("New base spacing"),
},
async (args) => {
const result = await compassApi(
apiBaseUrl,
"/api/compass/themes/edit",
authToken,
args
)
return {
content: [{ type: "text", text: JSON.stringify(result) }]
}
}
)
]
}

View File

@ -0,0 +1,119 @@
import { tool } from "@anthropic-ai/claude-agent-sdk"
import { z } from "zod"
const VALID_ROUTES: ReadonlyArray<RegExp> = [
/^\/dashboard$/,
/^\/dashboard\/contacts$/,
/^\/dashboard\/customers$/,
/^\/dashboard\/vendors$/,
/^\/dashboard\/projects$/,
/^\/dashboard\/projects\/[^/]+$/,
/^\/dashboard\/projects\/[^/]+\/schedule$/,
/^\/dashboard\/financials$/,
/^\/dashboard\/people$/,
/^\/dashboard\/files$/,
/^\/dashboard\/files\/.+$/,
/^\/dashboard\/boards\/[^/]+$/,
/^\/dashboard\/conversations$/,
/^\/dashboard\/settings$/,
]
function isValidRoute(path: string): boolean {
return VALID_ROUTES.some((r) => r.test(path))
}
/** Try to normalize a path to a valid dashboard route */
function normalizePath(path: string): string {
let p = path.trim()
// Strip leading/trailing slashes for normalization
if (!p.startsWith("/dashboard")) {
// Try prepending /dashboard
p = p.startsWith("/") ? `/dashboard${p}` : `/dashboard/${p}`
}
return p
}
export function uiTools() {
return [
tool(
"navigateTo",
"Navigate the user to a page. Paths are relative to /dashboard — e.g. pass 'projects' or '/dashboard/projects'. Valid pages: projects, projects/{id}, projects/{id}/schedule, contacts, customers, vendors, financials, people, files, files/{path}, boards/{id}, conversations, settings.",
{
path: z.string().describe("Page path, e.g. 'projects' or '/dashboard/projects'"),
reason: z.string().optional().describe("Brief explanation of why navigating"),
},
async (args) => {
const resolved = normalizePath(args.path)
if (!isValidRoute(resolved)) {
return {
content: [{
type: "text",
text: JSON.stringify({
error:
`"${args.path}" (resolved to "${resolved}") is not a valid page. ` +
"Valid: projects, projects/{id}, contacts, " +
"financials, people, files, boards/{id}",
})
}]
}
}
return {
content: [{
type: "text",
text: JSON.stringify({
action: "navigate",
path: resolved,
reason: args.reason ?? null,
})
}]
}
}
),
tool(
"showNotification",
"Show a toast notification to the user. Use for confirmations or important alerts.",
{
message: z.string().describe("The notification message"),
type: z.enum(["default", "success", "error"]).optional().describe("Notification style"),
},
async (args) => {
return {
content: [{
type: "text",
text: JSON.stringify({
action: "toast",
message: args.message,
type: args.type ?? "default",
})
}]
}
}
),
tool(
"generateUI",
"Generate a rich interactive UI dashboard. Use when the user wants to see structured data (tables, charts, stats, forms). Always fetch data with queryData first, then pass it here as dataContext.",
{
description: z.string().describe(
"Layout and content description for the dashboard to generate. Be specific about what components and data to display."
),
dataContext: z.record(z.string(), z.unknown()).optional().describe(
"Data to include in the rendered UI. Pass query results here."
),
},
async (args) => {
return {
content: [{
type: "text",
text: JSON.stringify({
action: "generateUI",
renderPrompt: args.description,
dataContext: args.dataContext ?? {},
})
}]
}
}
)
]
}

View File

@ -0,0 +1,60 @@
/**
* In-memory session store for SDK session state
*/
export interface SessionState {
sessionId: string
createdAt: Date
lastAccessedAt: Date
metadata?: Record<string, unknown>
}
const sessions = new Map<string, SessionState>()
export function getOrCreateSession(sessionId: string): SessionState {
let session = sessions.get(sessionId)
if (!session) {
session = {
sessionId,
createdAt: new Date(),
lastAccessedAt: new Date(),
}
sessions.set(sessionId, session)
} else {
session.lastAccessedAt = new Date()
}
return session
}
export function deleteSession(sessionId: string): boolean {
return sessions.delete(sessionId)
}
export function getAllSessions(): SessionState[] {
return Array.from(sessions.values())
}
// Cleanup sessions older than 1 hour
export function cleanupStaleSessions(maxAgeMs: number = 3600000): number {
const now = Date.now()
let deleted = 0
for (const [sessionId, session] of sessions.entries()) {
if (now - session.lastAccessedAt.getTime() > maxAgeMs) {
sessions.delete(sessionId)
deleted++
}
}
return deleted
}
// Run cleanup every 10 minutes
setInterval(() => {
const deleted = cleanupStaleSessions()
if (deleted > 0) {
console.log(`Cleaned up ${deleted} stale sessions`)
}
}, 600000)

View File

@ -0,0 +1,419 @@
/**
* SSE streaming wrapper for Anthropic Agent SDK
*
* The SDK's query() yields SDKMessage union types:
* - SDKAssistantMessage (type: "assistant") completed message with content blocks
* - SDKPartialAssistantMessage (type: "stream_event") streaming deltas
* - SDKResultMessage (type: "result") final result with usage
* - SDKToolProgressMessage (type: "tool_progress") tool execution status
* - SDKSystemMessage, SDKStatusMessage, etc. internal, not forwarded
*
* We convert these into a flat SSE protocol the browser can consume:
* data: {"type":"text_delta","content":"Hello"}
* data: {"type":"tool_use","name":"queryData","toolCallId":"...","input":{}}
* data: {"type":"tool_result","toolCallId":"...","output":{}}
* data: {"type":"result","subtype":"success","result":"...","usage":{}}
* data: {"type":"error","error":"..."}
* data: [DONE]
*/
import {
query,
type SDKMessage,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk"
import type { MessageParam } from "@anthropic-ai/sdk/resources"
import type {
BetaRawContentBlockStartEvent,
BetaRawContentBlockDeltaEvent,
} from "@anthropic-ai/sdk/resources/beta/messages/messages"
import type { AuthContext, ProviderConfig } from "./auth"
import { createCompassMcpServer } from "./mcp/compass-server"
import { config } from "./config"
interface Message {
role: "user" | "assistant"
content: string
}
interface StreamContext {
auth: AuthContext
authToken: string
sessionId: string
currentPage: string
timezone: string
provider?: ProviderConfig
model: string
}
/**
* Convert messages array to async generator (required by SDK when using MCP).
* Only yields the LAST user message earlier conversation history is
* injected into the system prompt so the model has context without
* the SDK re-processing old turns.
*/
async function* createPromptGenerator(
messages: readonly Message[],
sessionId: string,
): AsyncGenerator<SDKUserMessage> {
// find the last user message
const lastUser = [...messages].reverse().find((m) => m.role === "user")
if (!lastUser) return
const messageParam: MessageParam = {
role: "user",
content: lastUser.content,
}
yield {
type: "user" as const,
message: messageParam,
parent_tool_use_id: null,
session_id: sessionId,
}
}
/**
* Build conversation history block for the system prompt.
* Includes all messages EXCEPT the last user message (which is
* yielded separately via the prompt generator).
*/
function buildConversationHistory(messages: readonly Message[]): string {
// everything except the final user message
const history = messages.slice(0, -1)
if (history.length === 0) return ""
const lines = history.map((m) => {
const role = m.role === "user" ? "User" : "Assistant"
return `${role}: ${m.content}`
})
return `\n\nConversation so far:\n${lines.join("\n")}\n\nContinue the conversation naturally.`
}
function buildSystemPrompt(
context: StreamContext,
messages: readonly Message[],
): string {
const history = buildConversationHistory(messages)
return `You are Compass AI, an intelligent assistant for project management and collaboration.
Current context:
- User ID: ${context.auth.userId}
- Organization: ${context.auth.orgId}
- Role: ${context.auth.role}
- Current page: ${context.currentPage}
- Timezone: ${context.timezone}
You have access to tools via the compass MCP server for querying data, navigating the UI, managing schedules, themes, memories, skills, dashboards, and GitHub integration.
When a tool returns an "action" field in its result, that action will be forwarded to the client for execution (navigation, toasts, UI generation, theme changes, etc.). You don't need to do anything extra just call the tool and the action dispatches automatically.${history}`
}
/**
* SSE helpers each function returns a JSON string to send as a data: line
*/
function sseTextDelta(content: string): string {
return JSON.stringify({ type: "text", content })
}
function sseToolUse(name: string, toolCallId: string, input: unknown): string {
return JSON.stringify({ type: "tool_use", name, toolCallId, input })
}
function sseToolResult(toolCallId: string, output: unknown): string {
return JSON.stringify({ type: "tool_result", toolCallId, output })
}
function sseResult(
subtype: string,
result: string,
usage?: { inputTokens: number; outputTokens: number; totalCostUsd: number },
): string {
return JSON.stringify({ type: "result", subtype, result, usage })
}
function sseError(error: string): string {
return JSON.stringify({ type: "error", error })
}
/**
* Extract SSE events from an SDKMessage.
* Returns an array because one SDK message can produce multiple SSE events
* (e.g. an assistant message with text + tool_use blocks).
*/
function extractSSEEvents(message: SDKMessage, emittedToolIds?: Set<string>): string[] {
const events: string[] = []
switch (message.type) {
// Completed assistant message — SKIP all content blocks.
// Text is already streamed via stream_event text_delta.
// Tool use starts are already streamed via content_block_start.
// Tool results come through user messages (see case "user" below).
case "assistant":
break
// Streaming delta — text chunks and tool use starts
case "stream_event": {
const event = message.event
if (!event) break
if (event.type === "content_block_start") {
const startEvent = event as BetaRawContentBlockStartEvent
const block = startEvent.content_block
if (block && "type" in block) {
if ((block.type === "tool_use" || block.type === "mcp_tool_use") && "id" in block) {
const tb = block as { id: string; name: string; input: unknown }
if (!emittedToolIds?.has(tb.id)) {
emittedToolIds?.add(tb.id)
events.push(sseToolUse(tb.name, tb.id, tb.input ?? {}))
}
}
}
}
if (event.type === "content_block_delta") {
const deltaEvent = event as BetaRawContentBlockDeltaEvent
const delta = deltaEvent.delta
if (delta && "type" in delta) {
if (delta.type === "text_delta" && "text" in delta) {
events.push(sseTextDelta(delta.text as string))
}
// input_json_delta — partial tool input, skip for now
// (the full input comes in content_block_start or assistant message)
}
}
break
}
// Final result
case "result": {
if (message.subtype === "success") {
events.push(sseResult(
"success",
(message as { result?: string }).result ?? "",
{
inputTokens: message.usage?.input_tokens ?? 0,
outputTokens: message.usage?.output_tokens ?? 0,
totalCostUsd: (message as { total_cost_usd?: number }).total_cost_usd ?? 0,
},
))
} else {
const errMsg = message as { errors?: string[] }
events.push(sseError(
errMsg.errors?.join("; ") ?? `Agent error: ${message.subtype}`,
))
}
break
}
// Tool progress — optional, forward as status
case "tool_progress": {
const tp = message as {
tool_name: string
tool_use_id: string
elapsed_time_seconds: number
}
events.push(JSON.stringify({
type: "tool_progress",
toolName: tp.tool_name,
toolCallId: tp.tool_use_id,
elapsedSeconds: tp.elapsed_time_seconds,
}))
break
}
// User messages contain tool results from MCP tool execution.
// The SDK sends tool_result blocks inside user messages after
// executing MCP tools. We need to forward these to the client
// so action payloads (navigate, toast, etc.) get dispatched.
case "user": {
const userMsg = (message as { message?: { content?: unknown[] } }).message
if (!userMsg?.content || !Array.isArray(userMsg.content)) break
for (const block of userMsg.content) {
const b = block as Record<string, unknown>
if (
(b.type === "tool_result" || b.type === "mcp_tool_result") &&
typeof b.tool_use_id === "string"
) {
// MCP tool results wrap content as [{type:"text", text:"..."}]
// Unwrap and parse the JSON text to get the actual output
const content = b.content as Array<{ type: string; text?: string }> | undefined
let output: unknown = content
if (Array.isArray(content) && content.length === 1 && content[0].type === "text" && content[0].text) {
try {
output = JSON.parse(content[0].text)
} catch {
output = content[0].text
}
}
events.push(sseToolResult(b.tool_use_id as string, output))
}
}
break
}
// Internal SDK messages — skip
case "system":
default:
break
}
return events
}
/**
* Build a clean env for the Claude Code subprocess.
* The SDK spawns the claude CLI as a child process, which reads
* ANTHROPIC_API_KEY from its env. We also need to unset CLAUDECODE
* to avoid the nested-session guard.
*/
function buildSubprocessEnv(provider?: ProviderConfig): Record<string, string | undefined> {
const env: Record<string, string | undefined> = { ...process.env }
// Unset nested-session guard
delete env.CLAUDECODE
// Handle provider-specific configuration
if (provider) {
switch (provider.type) {
case "anthropic-oauth":
// Use OAuth credentials from ~/.claude/.credentials.json
delete env.ANTHROPIC_API_KEY
delete env.ANTHROPIC_BASE_URL
break
case "anthropic-key":
// Direct Anthropic API with user's key
if (provider.apiKey) {
env.ANTHROPIC_API_KEY = provider.apiKey
}
delete env.ANTHROPIC_BASE_URL
break
case "openrouter":
// OpenRouter proxy
env.ANTHROPIC_BASE_URL = "https://openrouter.ai/api"
env.ANTHROPIC_AUTH_TOKEN = provider.apiKey || ""
env.ANTHROPIC_API_KEY = ""
break
case "ollama":
// Local Ollama instance
if (provider.baseUrl) {
env.ANTHROPIC_BASE_URL = provider.baseUrl
}
env.ANTHROPIC_API_KEY = "ollama"
break
case "custom":
// Custom endpoint (e.g., LiteLLM, vLLM)
if (provider.baseUrl) {
env.ANTHROPIC_BASE_URL = provider.baseUrl
}
if (provider.apiKey) {
env.ANTHROPIC_API_KEY = provider.apiKey
}
break
}
// Apply model overrides if provided
if (provider.modelOverrides) {
if (provider.modelOverrides.sonnet) {
env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.modelOverrides.sonnet
}
if (provider.modelOverrides.opus) {
env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.modelOverrides.opus
}
if (provider.modelOverrides.haiku) {
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.modelOverrides.haiku
}
}
} else if (config.anthropicApiKey) {
// Fallback to server-configured API key
env.ANTHROPIC_API_KEY = config.anthropicApiKey
if (config.anthropicBaseUrl) {
env.ANTHROPIC_BASE_URL = config.anthropicBaseUrl
}
} else {
// No provider or config — use OAuth
delete env.ANTHROPIC_API_KEY
delete env.ANTHROPIC_BASE_URL
}
return env
}
/**
* Create SSE stream from SDK query()
*/
export async function createAgentStream(
messages: Message[],
context: StreamContext,
): Promise<ReadableStream<Uint8Array>> {
const encoder = new TextEncoder()
return new ReadableStream({
async start(controller) {
try {
const promptGen = createPromptGenerator(messages, context.sessionId)
const compassMcpServer = createCompassMcpServer(
config.compassApiBaseUrl,
context.authToken,
)
const subprocessEnv = buildSubprocessEnv(context.provider)
// Use a unique session ID per query() call. The SDK passes this
// to the Claude Code CLI subprocess, which uses it for session
// state/locks. Reusing the same ID across calls causes the
// subprocess to crash with exit code 1 on subsequent requests.
// Conversation continuity is handled via system prompt history.
const querySessionId = crypto.randomUUID()
const stream = query({
prompt: promptGen,
options: {
systemPrompt: buildSystemPrompt(context, messages),
model: context.model,
env: subprocessEnv,
settingSources: [],
mcpServers: {
compass: compassMcpServer,
},
allowedTools: ["mcp__compass__*"],
maxTurns: 25,
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
tools: [],
includePartialMessages: true,
sessionId: querySessionId,
},
})
// Track emitted tool IDs to prevent duplicates.
// The SDK can emit the same tool call as both tool_use and
// mcp_tool_use in content_block_start events.
const emittedToolIds = new Set<string>()
for await (const message of stream) {
const events = extractSSEEvents(message, emittedToolIds)
for (const event of events) {
controller.enqueue(encoder.encode(`data: ${event}\n\n`))
}
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
controller.close()
} catch (err) {
console.error("Agent stream error:", err)
controller.enqueue(encoder.encode(`data: ${sseError(String(err))}\n\n`))
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
controller.close()
}
},
})
}

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2024",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2a.9.9 0 0 1 .84.58l2.32 5.94a4.5 4.5 0 0 0 2.6 2.6l5.94 2.32a.9.9 0 0 1 0 1.67l-5.94 2.32a4.5 4.5 0 0 0-2.6 2.6l-2.32 5.94a.9.9 0 0 1-1.68 0l-2.32-5.94a4.5 4.5 0 0 0-2.6-2.6L.3 15.11a.9.9 0 0 1 0-1.67l5.94-2.32a4.5 4.5 0 0 0 2.6-2.6L11.16 2.58A.9.9 0 0 1 12 2Z" fill="#D97757"/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2C9 2 7 4.5 7 7.5c0 1.5.5 2.8 1.3 3.8C7.5 12.3 7 13.6 7 15c0 3 2.2 5 5 5s5-2 5-5c0-1.4-.5-2.7-1.3-3.7.8-1 1.3-2.3 1.3-3.8C17 4.5 15 2 12 2z"/>
<circle cx="10" cy="7" r="1"/>
<circle cx="14" cy="7" r="1"/>
<path d="M10 11c.7.7 1.3 1 2 1s1.3-.3 2-1"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -0,0 +1,56 @@
"use server"
import { SignJWT } from "jose"
import { getCurrentUser } from "@/lib/auth"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { isDemoUser } from "@/lib/demo"
import { getProviderConfigForJwt } from "./provider-config"
const ORG_DEFAULT_USER_ID = "org_default"
/**
* Generate a JWT for the browser to use when connecting to the agent server
* Token includes user identity, org context, and role for authorization
*/
export async function getAgentToken(): Promise<
{ token: string } | { error: string }
> {
const user = await getCurrentUser()
if (!user) {
return { error: "Not authenticated" }
}
const { env } = await getCloudflareContext()
const secret = (env as unknown as Record<string, string>)
.AGENT_AUTH_SECRET
if (!secret) {
return { error: "Agent auth not configured" }
}
try {
let providerConfig = await getProviderConfigForJwt(user.id)
if (!providerConfig) {
providerConfig = await getProviderConfigForJwt(ORG_DEFAULT_USER_ID)
}
const token = await new SignJWT({
sub: user.id,
orgId: user.organizationId,
role: user.role,
isDemoUser: isDemoUser(user.id),
provider: providerConfig ?? undefined,
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(new TextEncoder().encode(secret))
return { token }
} catch (err) {
return {
error: err instanceof Error ? err.message : "Failed to generate token",
}
}
}

View File

@ -518,9 +518,7 @@ export async function updateModelPolicy(
.where(eq(agentConfig.id, "global")) .where(eq(agentConfig.id, "global"))
.run() .run()
} else { } else {
const { const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
DEFAULT_MODEL_ID,
} = await import("@/lib/agent/provider")
await db await db
.insert(agentConfig) .insert(agentConfig)
.values({ .values({

View File

@ -0,0 +1,436 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { getDb } from "@/db"
import {
userProviderConfig,
agentConfig,
} from "@/db/schema-ai-config"
import { getCurrentUser } from "@/lib/auth"
import { can } from "@/lib/permissions"
import { encrypt, decrypt } from "@/lib/crypto"
import { isDemoUser } from "@/lib/demo"
// --- constants ---
const ORG_DEFAULT_USER_ID = "org_default"
// --- types ---
interface ProviderConfigData {
readonly providerType: string
readonly hasApiKey: boolean
readonly baseUrl: string | null
readonly modelOverrides: Record<string, string> | null
readonly isActive: boolean
}
interface ProviderConfigForJwt {
readonly type: string
readonly apiKey: string | null
readonly baseUrl: string | null
readonly modelOverrides: Record<string, string> | null
}
// --- actions ---
export async function getUserProviderConfig(): Promise<
| { success: true; data: ProviderConfigData | null }
| { success: false; error: string }
> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(userProviderConfig)
.where(eq(userProviderConfig.userId, user.id))
.get()
if (!config) {
return { success: true, data: null }
}
let modelOverrides: Record<string, string> | null = null
if (config.modelOverrides) {
try {
modelOverrides = JSON.parse(
config.modelOverrides
) as Record<string, string>
} catch {
modelOverrides = null
}
}
return {
success: true,
data: {
providerType: config.providerType,
hasApiKey: config.apiKey !== null,
baseUrl: config.baseUrl,
modelOverrides,
isActive: config.isActive === 1,
},
}
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to get provider config",
}
}
}
export async function getProviderConfigForJwt(
userId: string
): Promise<ProviderConfigForJwt | null> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(userProviderConfig)
.where(eq(userProviderConfig.userId, userId))
.get()
if (!config) {
return null
}
const encryptionKey = (
env as unknown as Record<string, string>
).PROVIDER_KEY_ENCRYPTION_KEY
if (!encryptionKey) {
return null
}
let decryptedApiKey: string | null = null
if (config.apiKey) {
try {
decryptedApiKey = await decrypt(
config.apiKey,
encryptionKey,
userId
)
} catch {
decryptedApiKey = null
}
}
let modelOverrides: Record<string, string> | null = null
if (config.modelOverrides) {
try {
modelOverrides = JSON.parse(
config.modelOverrides
) as Record<string, string>
} catch {
modelOverrides = null
}
}
return {
type: config.providerType,
apiKey: decryptedApiKey,
baseUrl: config.baseUrl,
modelOverrides,
}
} catch {
return null
}
}
export async function setUserProviderConfig(
providerType: string,
apiKey?: string,
baseUrl?: string,
modelOverrides?: Record<string, string>
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
const isAdmin = can(user, "agent", "update")
if (
!isAdmin &&
config &&
config.allowUserSelection !== 1
) {
return {
success: false,
error: "User provider selection is disabled",
}
}
const encryptionKey = (
env as unknown as Record<string, string>
).PROVIDER_KEY_ENCRYPTION_KEY
if (!encryptionKey) {
return {
success: false,
error:
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
}
}
let encryptedApiKey: string | null = null
if (apiKey) {
encryptedApiKey = await encrypt(
apiKey,
encryptionKey,
user.id
)
}
const now = new Date().toISOString()
const modelOverridesJson = modelOverrides
? JSON.stringify(modelOverrides)
: null
await db
.insert(userProviderConfig)
.values({
userId: user.id,
providerType,
apiKey: encryptedApiKey,
baseUrl: baseUrl ?? null,
modelOverrides: modelOverridesJson,
isActive: 1,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProviderConfig.userId,
set: {
providerType,
apiKey: encryptedApiKey,
baseUrl: baseUrl ?? null,
modelOverrides: modelOverridesJson,
isActive: 1,
updatedAt: now,
},
})
.run()
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to set provider config",
}
}
}
export async function clearUserProviderConfig(): Promise<{
success: boolean
error?: string
}> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.delete(userProviderConfig)
.where(eq(userProviderConfig.userId, user.id))
.run()
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to clear provider config",
}
}
}
export async function getOrgProviderConfig(): Promise<
| { success: true; data: ProviderConfigData | null }
| { success: false; error: string }
> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (!can(user, "agent", "update")) {
return {
success: false,
error: "Permission denied",
}
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(userProviderConfig)
.where(
eq(userProviderConfig.userId, ORG_DEFAULT_USER_ID)
)
.get()
if (!config) {
return { success: true, data: null }
}
let modelOverrides: Record<string, string> | null = null
if (config.modelOverrides) {
try {
modelOverrides = JSON.parse(
config.modelOverrides
) as Record<string, string>
} catch {
modelOverrides = null
}
}
return {
success: true,
data: {
providerType: config.providerType,
hasApiKey: config.apiKey !== null,
baseUrl: config.baseUrl,
modelOverrides,
isActive: config.isActive === 1,
},
}
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to get org provider config",
}
}
}
export async function setOrgProviderConfig(
providerType: string,
apiKey?: string,
baseUrl?: string
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (!can(user, "agent", "update")) {
return {
success: false,
error: "Permission denied",
}
}
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const encryptionKey = (
env as unknown as Record<string, string>
).PROVIDER_KEY_ENCRYPTION_KEY
if (!encryptionKey) {
return {
success: false,
error:
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
}
}
let encryptedApiKey: string | null = null
if (apiKey) {
encryptedApiKey = await encrypt(
apiKey,
encryptionKey,
ORG_DEFAULT_USER_ID
)
}
const now = new Date().toISOString()
await db
.insert(userProviderConfig)
.values({
userId: ORG_DEFAULT_USER_ID,
providerType,
apiKey: encryptedApiKey,
baseUrl: baseUrl ?? null,
modelOverrides: null,
isActive: 1,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProviderConfig.userId,
set: {
providerType,
apiKey: encryptedApiKey,
baseUrl: baseUrl ?? null,
isActive: 1,
updatedAt: now,
},
})
.run()
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to set org provider config",
}
}
}

View File

@ -1,7 +1,11 @@
import { streamText } from "ai" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getAgentModel } from "@/lib/agent/provider"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { compassCatalog } from "@/lib/agent/render/catalog" import { compassCatalog } from "@/lib/agent/render/catalog"
import { getDb } from "@/db"
import { agentConfig } from "@/db/schema-ai-config"
import { eq } from "drizzle-orm"
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
const SYSTEM_PROMPT = compassCatalog.prompt({ const SYSTEM_PROMPT = compassCatalog.prompt({
customRules: [ customRules: [
@ -53,6 +57,31 @@ const SYSTEM_PROMPT = compassCatalog.prompt({
const MAX_PROMPT_LENGTH = 2000 const MAX_PROMPT_LENGTH = 2000
async function getModelConfig(): Promise<{
apiKey: string
modelId: string
}> {
const { env } = await getCloudflareContext()
const apiKey = (env as unknown as Record<string, string>)
.OPENROUTER_API_KEY
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY not configured")
}
const db = getDb(env.DB)
const config = await db
.select({ modelId: agentConfig.modelId })
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
return {
apiKey,
modelId: config?.modelId ?? DEFAULT_MODEL_ID,
}
}
export async function POST( export async function POST(
req: Request req: Request
): Promise<Response> { ): Promise<Response> {
@ -61,7 +90,10 @@ export async function POST(
return new Response("Unauthorized", { status: 401 }) return new Response("Unauthorized", { status: 401 })
} }
let body: { prompt?: string; context?: Record<string, unknown> } let body: {
prompt?: string
context?: Record<string, unknown>
}
try { try {
body = (await req.json()) as { body = (await req.json()) as {
prompt?: string prompt?: string
@ -70,14 +102,20 @@ export async function POST(
} catch { } catch {
return new Response( return new Response(
JSON.stringify({ error: "Invalid JSON body" }), JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } } {
status: 400,
headers: { "Content-Type": "application/json" },
}
) )
} }
const { prompt, context } = body const { prompt, context } = body
const previousSpec = context?.previousSpec as const previousSpec = context?.previousSpec as
| { root?: string; elements?: Record<string, unknown> } | {
root?: string
elements?: Record<string, unknown>
}
| undefined | undefined
const sanitizedPrompt = String(prompt || "").slice( const sanitizedPrompt = String(prompt || "").slice(
@ -87,15 +125,15 @@ export async function POST(
let userPrompt = sanitizedPrompt let userPrompt = sanitizedPrompt
// include data context if provided
const dataContext = context?.dataContext as const dataContext = context?.dataContext as
| Record<string, unknown> | Record<string, unknown>
| undefined | undefined
if (dataContext && Object.keys(dataContext).length > 0) { if (dataContext && Object.keys(dataContext).length > 0) {
userPrompt += `\n\nAVAILABLE DATA:\n${JSON.stringify(dataContext, null, 2)}` userPrompt +=
`\n\nAVAILABLE DATA:\n` +
JSON.stringify(dataContext, null, 2)
} }
// include previous spec for iterative updates
if ( if (
previousSpec?.root && previousSpec?.root &&
previousSpec.elements && previousSpec.elements &&
@ -115,14 +153,89 @@ IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to m
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.` DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`
} }
const model = await getAgentModel() const { apiKey, modelId } = await getModelConfig()
const result = streamText({ // call OpenRouter directly (OpenAI-compatible streaming)
model, const response = await fetch(
system: SYSTEM_PROMPT, "https://openrouter.ai/api/v1/chat/completions",
prompt: userPrompt, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://compass.build",
"X-Title": "Compass",
},
body: JSON.stringify({
model: modelId,
stream: true,
temperature: 0.7, temperature: 0.7,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
}),
}
)
if (!response.ok || !response.body) {
return new Response("Model API error", {
status: 502,
})
}
// transform OpenAI SSE stream into plain text stream
// that useUIStream from @json-render/react expects
const reader = response.body.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) {
controller.close()
return
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6).trim()
if (data === "[DONE]") {
controller.close()
return
}
try {
const parsed = JSON.parse(data) as {
choices?: ReadonlyArray<{
delta?: { content?: string }
}>
}
const content =
parsed.choices?.[0]?.delta?.content
if (content) {
controller.enqueue(encoder.encode(content))
}
} catch {
// skip malformed chunks
}
}
}
},
}) })
return result.toTextStreamResponse() return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
})
} }

View File

@ -1,174 +0,0 @@
import {
streamText,
stepCountIs,
convertToModelMessages,
RetryError,
type UIMessage,
} from "ai"
import { APICallError } from "@ai-sdk/provider"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import {
resolveModelForUser,
createModelFromId,
DEFAULT_MODEL_ID,
} from "@/lib/agent/provider"
import { agentTools } from "@/lib/agent/tools"
import { githubTools } from "@/lib/agent/github-tools"
import { buildSystemPrompt } from "@/lib/agent/system-prompt"
import { loadMemoriesForPrompt } from "@/lib/agent/memory"
import { getRegistry } from "@/lib/agent/plugins/registry"
import { saveStreamUsage } from "@/lib/agent/usage"
import { getCurrentUser } from "@/lib/auth"
import { getDb } from "@/db"
import { isDemoUser } from "@/lib/demo"
export async function POST(req: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) {
return new Response("Unauthorized", { status: 401 })
}
const { env, ctx } = await getCloudflareContext()
const db = getDb(env.DB)
const envRecord = env as unknown as Record<string, string>
const apiKey = envRecord.OPENROUTER_API_KEY
if (!apiKey) {
return new Response(
JSON.stringify({
error: "OPENROUTER_API_KEY not configured",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
const { getCustomDashboards } = await import(
"@/app/actions/dashboards"
)
const [memories, registry, dashboardResult] =
await Promise.all([
loadMemoriesForPrompt(db, user.id),
getRegistry(db, envRecord),
getCustomDashboards(),
])
const pluginSections = registry.getPromptSections()
const pluginTools = registry.getTools()
let body: { messages: UIMessage[] }
try {
body = (await req.json()) as { messages: UIMessage[] }
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
const currentPage =
req.headers.get("x-current-page") ?? undefined
const timezone =
req.headers.get("x-timezone") ?? undefined
const conversationId =
req.headers.get("x-conversation-id") ||
crypto.randomUUID()
let modelId = await resolveModelForUser(db, user.id)
if (!modelId || !modelId.includes("/")) {
console.error(
`Invalid model ID resolved: "${modelId}",` +
` falling back to default`
)
modelId = DEFAULT_MODEL_ID
}
const model = createModelFromId(apiKey, modelId)
// detect demo mode
const isDemo = isDemoUser(user.id)
const result = streamText({
model,
system: buildSystemPrompt({
userName: user.displayName ?? user.email,
userRole: user.role,
currentPage,
timezone,
memories,
pluginSections,
dashboards: dashboardResult.success
? dashboardResult.data
: [],
mode: isDemo ? "demo" : "full",
}),
messages: await convertToModelMessages(
body.messages
),
tools: {
...agentTools,
...githubTools,
...pluginTools,
},
toolChoice: "auto",
stopWhen: stepCountIs(10),
onError({ error }) {
const apiErr = unwrapAPICallError(error)
if (apiErr) {
console.error(
`Agent API error [model=${modelId}]`,
`status=${apiErr.statusCode}`,
`body=${apiErr.responseBody}`
)
} else {
const msg =
error instanceof Error
? error.message
: String(error)
console.error(
`Agent error [model=${modelId}]:`,
msg
)
}
},
})
ctx.waitUntil(
saveStreamUsage(
db,
conversationId,
user.id,
modelId,
result
)
)
return result.toUIMessageStreamResponse({
onError(error) {
const apiErr = unwrapAPICallError(error)
if (apiErr) {
return (
apiErr.responseBody ??
`Provider error (${apiErr.statusCode})`
)
}
return error instanceof Error
? error.message
: "Unknown error"
},
})
}
function unwrapAPICallError(
error: unknown
): APICallError | undefined {
if (APICallError.isInstance(error)) return error
if (RetryError.isInstance(error)) {
const last: unknown = error.lastError
if (APICallError.isInstance(last)) return last
}
return undefined
}

View File

@ -0,0 +1,148 @@
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import {
getCustomDashboards,
getCustomDashboardById,
deleteCustomDashboard,
} from "@/app/actions/dashboards"
type DashboardAction = "list" | "get" | "delete"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: { action: DashboardAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "list": {
const result = await getCustomDashboards()
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
dashboards: result.data,
count: result.data.length,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "get": {
const dashboardId = body.dashboardId as string
if (!dashboardId) {
return new Response(
JSON.stringify({ error: "dashboardId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await getCustomDashboardById(dashboardId)
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
dashboard: result.data,
spec: JSON.parse(result.data.specData),
queries: result.data.queries,
renderPrompt: result.data.renderPrompt,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "delete": {
const dashboardId = body.dashboardId as string
if (!dashboardId) {
return new Response(
JSON.stringify({ error: "dashboardId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await deleteCustomDashboard(dashboardId)
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
success: true,
message: "Dashboard deleted",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Dashboards endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,290 @@
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import {
getGitHubConfig,
fetchCommits,
fetchCommitDiff,
fetchPullRequests,
fetchIssues,
fetchContributors,
fetchMilestones,
fetchRepoStats,
createIssue,
} from "@/lib/github/client"
type GitHubAction = "query" | "createIssue"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: { action: GitHubAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
const cfgResult = await getGitHubConfig()
if (!cfgResult.success) {
return new Response(
JSON.stringify({ error: cfgResult.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const cfg = cfgResult.config
try {
switch (body.action) {
case "query": {
const queryType = body.queryType as string
const sha = body.sha as string | undefined
const state = (body.state as "open" | "closed" | "all") ?? "open"
const labels = body.labels as string | undefined
const limit = (body.limit as number) ?? 10
if (!queryType) {
return new Response(
JSON.stringify({ error: "queryType required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
switch (queryType) {
case "commits": {
const res = await fetchCommits(cfg, limit)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "commit_diff": {
if (!sha) {
return new Response(
JSON.stringify({
error: "sha is required for commit_diff",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const res = await fetchCommitDiff(cfg, sha)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "pull_requests": {
const res = await fetchPullRequests(cfg, state, limit)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "issues": {
const res = await fetchIssues(cfg, state, labels, limit)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "contributors": {
const res = await fetchContributors(cfg)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "milestones": {
const res = await fetchMilestones(cfg, state)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "repo_stats": {
const res = await fetchRepoStats(cfg)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data }),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown query type" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
}
case "createIssue": {
const title = body.title as string
const bodyText = body.body as string
const labels = body.labels as string[] | undefined
const assignee = body.assignee as string | undefined
const milestone = body.milestone as number | undefined
if (!title || !bodyText) {
return new Response(
JSON.stringify({ error: "title and body required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const res = await createIssue(
cfg,
title,
bodyText,
labels,
assignee,
milestone,
)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
success: true,
data: {
issueNumber: res.data.number,
issueUrl: res.data.url,
title: res.data.title,
},
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("GitHub endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,132 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { saveMemory, searchMemories } from "@/lib/agent/memory"
type MemoryAction = "save" | "search"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
const db = getDb(env.DB)
let body: { action: MemoryAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "save": {
const content = body.content as string
const memoryType = body.memoryType as
| "preference"
| "workflow"
| "fact"
| "decision"
const tags = (body.tags as string) ?? undefined
const importance = (body.importance as number) ?? undefined
if (!content || !memoryType) {
return new Response(
JSON.stringify({
error: "content and memoryType required",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const id = await saveMemory(
db,
auth.userId,
content,
memoryType,
tags,
importance,
)
return new Response(
JSON.stringify({
success: true,
id,
content,
memoryType,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "search": {
const query = body.query as string
const limit = (body.limit as number) ?? 5
if (!query) {
return new Response(
JSON.stringify({ error: "query required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const results = await searchMemories(
db,
auth.userId,
query,
limit,
)
return new Response(
JSON.stringify({
success: true,
results,
count: results.length,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Memory endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,170 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import {
getUserProviderConfig,
setUserProviderConfig,
clearUserProviderConfig,
} from "@/app/actions/provider-config"
export async function GET(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
try {
const result = await getUserProviderConfig()
if (!result.success) {
return new Response(JSON.stringify({ error: result.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
return new Response(
JSON.stringify({
success: true,
data: result.data,
}),
{
headers: { "Content-Type": "application/json" },
}
)
} catch (error) {
console.error("Provider config GET error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: {
type: string
apiKey?: string
baseUrl?: string
modelOverrides?: Record<string, string>
}
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
const { type, apiKey, baseUrl, modelOverrides } = body
if (!type) {
return new Response(
JSON.stringify({ error: "type field is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
try {
const result = await setUserProviderConfig(
type,
apiKey,
baseUrl,
modelOverrides
)
if (!result.success) {
return new Response(JSON.stringify({ error: result.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
return new Response(
JSON.stringify({
success: true,
}),
{
headers: { "Content-Type": "application/json" },
}
)
} catch (error) {
console.error("Provider config POST error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}
export async function DELETE(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
try {
const result = await clearUserProviderConfig()
if (!result.success) {
return new Response(JSON.stringify({ error: result.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
return new Response(
JSON.stringify({
success: true,
}),
{
headers: { "Content-Type": "application/json" },
}
)
} catch (error) {
console.error("Provider config DELETE error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,306 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { projects, scheduleTasks } from "@/db/schema"
import { invoices, vendorBills } from "@/db/schema-netsuite"
import { eq, and, like } from "drizzle-orm"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
const db = getDb(env.DB)
let body: {
queryType: string
id?: string
search?: string
limit?: number
}
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
const orgId = auth.orgId
const cap = body.limit ?? 20
try {
switch (body.queryType) {
case "customers": {
const rows = await db.query.customers.findMany({
limit: cap,
where: (c, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
const conditions = [eqFunc(c.organizationId, orgId)]
if (body.search) {
conditions.push(likeFunc(c.name, `%${body.search}%`))
}
return conditions.length > 1
? andFunc(...conditions)
: conditions[0]
},
})
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "vendors": {
const rows = await db.query.vendors.findMany({
limit: cap,
where: (v, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
const conditions = [eqFunc(v.organizationId, orgId)]
if (body.search) {
conditions.push(likeFunc(v.name, `%${body.search}%`))
}
return conditions.length > 1
? andFunc(...conditions)
: conditions[0]
},
})
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "projects": {
const rows = await db.query.projects.findMany({
limit: cap,
where: (p, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
const conditions = [eqFunc(p.organizationId, orgId)]
if (body.search) {
conditions.push(likeFunc(p.name, `%${body.search}%`))
}
return conditions.length > 1
? andFunc(...conditions)
: conditions[0]
},
})
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "invoices": {
const rows = await db
.select({
id: invoices.id,
netsuiteId: invoices.netsuiteId,
customerId: invoices.customerId,
projectId: invoices.projectId,
invoiceNumber: invoices.invoiceNumber,
status: invoices.status,
issueDate: invoices.issueDate,
dueDate: invoices.dueDate,
subtotal: invoices.subtotal,
tax: invoices.tax,
total: invoices.total,
amountPaid: invoices.amountPaid,
amountDue: invoices.amountDue,
memo: invoices.memo,
lineItems: invoices.lineItems,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.innerJoin(projects, eq(invoices.projectId, projects.id))
.where(eq(projects.organizationId, orgId))
.limit(cap)
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "vendor_bills": {
const rows = await db
.select({
id: vendorBills.id,
netsuiteId: vendorBills.netsuiteId,
vendorId: vendorBills.vendorId,
projectId: vendorBills.projectId,
billNumber: vendorBills.billNumber,
status: vendorBills.status,
billDate: vendorBills.billDate,
dueDate: vendorBills.dueDate,
subtotal: vendorBills.subtotal,
tax: vendorBills.tax,
total: vendorBills.total,
amountPaid: vendorBills.amountPaid,
amountDue: vendorBills.amountDue,
memo: vendorBills.memo,
lineItems: vendorBills.lineItems,
createdAt: vendorBills.createdAt,
updatedAt: vendorBills.updatedAt,
})
.from(vendorBills)
.innerJoin(projects, eq(vendorBills.projectId, projects.id))
.where(eq(projects.organizationId, orgId))
.limit(cap)
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "schedule_tasks": {
const whereConditions = [eq(projects.organizationId, orgId)]
if (body.search) {
whereConditions.push(like(scheduleTasks.title, `%${body.search}%`))
}
const rows = await db
.select({
id: scheduleTasks.id,
projectId: scheduleTasks.projectId,
title: scheduleTasks.title,
startDate: scheduleTasks.startDate,
workdays: scheduleTasks.workdays,
endDateCalculated: scheduleTasks.endDateCalculated,
phase: scheduleTasks.phase,
status: scheduleTasks.status,
isCriticalPath: scheduleTasks.isCriticalPath,
isMilestone: scheduleTasks.isMilestone,
percentComplete: scheduleTasks.percentComplete,
assignedTo: scheduleTasks.assignedTo,
sortOrder: scheduleTasks.sortOrder,
createdAt: scheduleTasks.createdAt,
updatedAt: scheduleTasks.updatedAt,
})
.from(scheduleTasks)
.innerJoin(projects, eq(scheduleTasks.projectId, projects.id))
.where(
whereConditions.length > 1
? and(...whereConditions)
: whereConditions[0]
)
.limit(cap)
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "project_detail": {
if (!body.id) {
return new Response(
JSON.stringify({ error: "id required for detail query" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const row = await db.query.projects.findFirst({
where: (p, { eq: eqFunc, and: andFunc }) =>
andFunc(eqFunc(p.id, body.id!), eqFunc(p.organizationId, orgId)),
})
if (!row) {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
}
return new Response(JSON.stringify({ data: row }), {
headers: { "Content-Type": "application/json" },
})
}
case "customer_detail": {
if (!body.id) {
return new Response(
JSON.stringify({ error: "id required for detail query" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const row = await db.query.customers.findFirst({
where: (c, { eq: eqFunc, and: andFunc }) =>
andFunc(eqFunc(c.id, body.id!), eqFunc(c.organizationId, orgId)),
})
if (!row) {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
}
return new Response(JSON.stringify({ data: row }), {
headers: { "Content-Type": "application/json" },
})
}
case "vendor_detail": {
if (!body.id) {
return new Response(
JSON.stringify({ error: "id required for detail query" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const row = await db.query.vendors.findFirst({
where: (v, { eq: eqFunc, and: andFunc }) =>
andFunc(eqFunc(v.id, body.id!), eqFunc(v.organizationId, orgId)),
})
if (!row) {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
}
return new Response(JSON.stringify({ data: row }), {
headers: { "Content-Type": "application/json" },
})
}
default:
return new Response(
JSON.stringify({ error: "unknown query type" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Query endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,686 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { projects, scheduleTasks, taskDependencies, workdayExceptions } from "@/db/schema"
import { eq, asc, and } from "drizzle-orm"
import { calculateEndDate } from "@/lib/schedule/business-days"
import { findCriticalPath } from "@/lib/schedule/critical-path"
import { wouldCreateCycle } from "@/lib/schedule/dependency-validation"
import { propagateDates } from "@/lib/schedule/propagate-dates"
import { revalidatePath } from "next/cache"
import type {
TaskStatus,
DependencyType,
ExceptionCategory,
ExceptionRecurrence,
WorkdayExceptionData,
} from "@/lib/schedule/types"
type ScheduleAction =
| "getSchedule"
| "createTask"
| "updateTask"
| "deleteTask"
| "createDependency"
| "deleteDependency"
| "addException"
| "removeException"
async function fetchProjectExceptions(
db: ReturnType<typeof getDb>,
projectId: string,
): Promise<WorkdayExceptionData[]> {
const rows = await db
.select()
.from(workdayExceptions)
.where(eq(workdayExceptions.projectId, projectId))
return rows.map((r) => ({
...r,
category: r.category as ExceptionCategory,
recurrence: r.recurrence as ExceptionRecurrence,
}))
}
async function fetchProjectDeps(
db: ReturnType<typeof getDb>,
projectId: string,
) {
const tasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
const allDeps = await db.select().from(taskDependencies)
const taskIdSet = new Set(tasks.map((t) => t.id))
const deps = allDeps.filter(
(d) =>
taskIdSet.has(d.predecessorId) && taskIdSet.has(d.successorId),
)
return {
tasks: tasks.map((t) => ({
...t,
status: t.status as TaskStatus,
})),
deps: deps.map((d) => ({
...d,
type: d.type as DependencyType,
})),
}
}
async function recalcCriticalPathDirect(
db: ReturnType<typeof getDb>,
projectId: string,
): Promise<void> {
const tasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
const allDeps = await db.select().from(taskDependencies)
const taskIdSet = new Set(tasks.map((t) => t.id))
const projectDeps = allDeps.filter(
(d) =>
taskIdSet.has(d.predecessorId) && taskIdSet.has(d.successorId),
)
const criticalSet = findCriticalPath(
tasks.map((t) => ({
...t,
status: t.status as TaskStatus,
})),
projectDeps.map((d) => ({
...d,
type: d.type as DependencyType,
})),
)
for (const task of tasks) {
const isCritical = criticalSet.has(task.id)
if (task.isCriticalPath !== isCritical) {
await db
.update(scheduleTasks)
.set({ isCriticalPath: isCritical })
.where(eq(scheduleTasks.id, task.id))
}
}
}
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
const db = getDb(env.DB)
let body: { action: ScheduleAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "getSchedule": {
const projectId = body.projectId as string
if (!projectId) {
return new Response(
JSON.stringify({ error: "projectId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, auth.orgId)
)
)
.limit(1)
if (!project) {
return new Response(
JSON.stringify({ error: "Project not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
const { tasks: typedTasks, deps: typedDeps } =
await fetchProjectDeps(db, projectId)
const exceptions = await fetchProjectExceptions(db, projectId)
const total = typedTasks.length
const completed = typedTasks.filter(
(t) => t.status === "COMPLETE",
).length
const inProgress = typedTasks.filter(
(t) => t.status === "IN_PROGRESS",
).length
const blocked = typedTasks.filter(
(t) => t.status === "BLOCKED",
).length
const overallPercent =
total > 0
? Math.round(
typedTasks.reduce(
(sum, t) => sum + t.percentComplete,
0,
) / total,
)
: 0
const criticalPath = typedTasks
.filter((t) => t.isCriticalPath)
.map((t) => ({
id: t.id,
title: t.title,
status: t.status,
startDate: t.startDate,
endDate: t.endDateCalculated,
}))
return new Response(
JSON.stringify({
tasks: typedTasks,
dependencies: typedDeps,
exceptions,
summary: {
total,
completed,
inProgress,
blocked,
pending: total - completed - inProgress - blocked,
overallPercent,
criticalPath,
},
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "createTask": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const projectId = body.projectId as string
const title = body.title as string
const startDate = body.startDate as string
const workdays = body.workdays as number
const phase = body.phase as string
const isMilestone = (body.isMilestone as boolean) ?? false
const percentComplete = (body.percentComplete as number) ?? 0
const assignedTo = (body.assignedTo as string | undefined) ?? null
if (!projectId || !title || !startDate || workdays === undefined || !phase) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, auth.orgId)
)
)
.limit(1)
if (!project) {
return new Response(
JSON.stringify({ error: "Project not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
const exceptions = await fetchProjectExceptions(db, projectId)
const endDate = calculateEndDate(startDate, workdays, exceptions)
const now = new Date().toISOString()
const existing = await db
.select({ sortOrder: scheduleTasks.sortOrder })
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
.orderBy(asc(scheduleTasks.sortOrder))
const nextOrder =
existing.length > 0
? existing[existing.length - 1].sortOrder + 1
: 0
const id = crypto.randomUUID()
await db.insert(scheduleTasks).values({
id,
projectId,
title,
startDate,
workdays,
endDateCalculated: endDate,
phase,
status: "PENDING",
isCriticalPath: false,
isMilestone,
percentComplete,
assignedTo,
sortOrder: nextOrder,
createdAt: now,
updatedAt: now,
})
await recalcCriticalPathDirect(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: `Task "${title}" created`,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "updateTask": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const taskId = body.taskId as string
if (!taskId) {
return new Response(
JSON.stringify({ error: "taskId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
.limit(1)
if (!task) {
return new Response(
JSON.stringify({ error: "Task not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
const { action: _action, taskId: _taskId, status, ...fields } = body
const hasFields = Object.keys(fields).length > 0
if (hasFields) {
const exceptions = await fetchProjectExceptions(db, task.projectId)
const startDate = (fields.startDate as string) ?? task.startDate
const workdays = (fields.workdays as number) ?? task.workdays
const endDate = calculateEndDate(startDate, workdays, exceptions)
await db
.update(scheduleTasks)
.set({
...(fields.title ? { title: fields.title as string } : {}),
startDate,
workdays,
endDateCalculated: endDate,
...(fields.phase ? { phase: fields.phase as string } : {}),
...(fields.isMilestone !== undefined
? { isMilestone: fields.isMilestone as boolean }
: {}),
...(fields.percentComplete !== undefined
? { percentComplete: fields.percentComplete as number }
: {}),
...(fields.assignedTo !== undefined
? { assignedTo: fields.assignedTo as string | null }
: {}),
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, taskId))
// propagate date changes
const allTasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, task.projectId))
const allDeps = await db.select().from(taskDependencies)
const taskIdSet = new Set(allTasks.map((t) => t.id))
const projectDeps = allDeps
.filter(
(d) =>
taskIdSet.has(d.predecessorId) &&
taskIdSet.has(d.successorId),
)
.map((d) => ({
...d,
type: d.type as DependencyType,
}))
const updatedTask = {
...task,
status: task.status as TaskStatus,
startDate,
workdays,
endDateCalculated: endDate,
}
const typedAll = allTasks.map((t) =>
t.id === taskId
? updatedTask
: { ...t, status: t.status as TaskStatus },
)
const { updatedTasks } = propagateDates(
taskId,
typedAll,
projectDeps,
exceptions,
)
for (const [id, dates] of updatedTasks) {
await db
.update(scheduleTasks)
.set({
startDate: dates.startDate,
endDateCalculated: dates.endDateCalculated,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, id))
}
}
if (status) {
await db
.update(scheduleTasks)
.set({
status: status as TaskStatus,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, taskId))
}
if (!hasFields && !status) {
return new Response(
JSON.stringify({ error: "No fields provided to update" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
await recalcCriticalPathDirect(db, task.projectId)
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: "Task updated",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "deleteTask": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const taskId = body.taskId as string
if (!taskId) {
return new Response(
JSON.stringify({ error: "taskId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
.limit(1)
if (!task) {
return new Response(
JSON.stringify({ error: "Task not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
await db
.delete(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
await recalcCriticalPathDirect(db, task.projectId)
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: "Task deleted",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "createDependency": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const projectId = body.projectId as string
const predecessorId = body.predecessorId as string
const successorId = body.successorId as string
const type = body.type as DependencyType
const lagDays = (body.lagDays as number) ?? 0
if (!projectId || !predecessorId || !successorId || !type) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const { deps: existingDeps } = await fetchProjectDeps(db, projectId)
if (
wouldCreateCycle(existingDeps, predecessorId, successorId)
) {
return new Response(
JSON.stringify({
error: "This dependency would create a cycle",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const depId = crypto.randomUUID()
await db.insert(taskDependencies).values({
id: depId,
predecessorId,
successorId,
type,
lagDays,
})
const exceptions = await fetchProjectExceptions(db, projectId)
const { tasks: typedTasks } = await fetchProjectDeps(db, projectId)
const updatedDeps = [
...existingDeps,
{
id: depId,
predecessorId,
successorId,
type,
lagDays,
},
]
const { updatedTasks } = propagateDates(
predecessorId,
typedTasks,
updatedDeps,
exceptions,
)
for (const [id, dates] of updatedTasks) {
await db
.update(scheduleTasks)
.set({
startDate: dates.startDate,
endDateCalculated: dates.endDateCalculated,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, id))
}
await recalcCriticalPathDirect(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: "Dependency created",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "deleteDependency": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const dependencyId = body.dependencyId as string
const projectId = body.projectId as string
if (!dependencyId || !projectId) {
return new Response(
JSON.stringify({ error: "dependencyId and projectId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
await db
.delete(taskDependencies)
.where(eq(taskDependencies.id, dependencyId))
await recalcCriticalPathDirect(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: "Dependency removed",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Schedule endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,153 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import {
installSkill as installSkillAction,
uninstallSkill as uninstallSkillAction,
toggleSkill as toggleSkillAction,
getInstalledSkills as getInstalledSkillsAction,
} from "@/app/actions/plugins"
type SkillAction = "list" | "install" | "toggle" | "uninstall"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: { action: SkillAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "list": {
const result = await getInstalledSkillsAction()
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
})
}
case "install": {
if (auth.role !== "admin") {
return new Response(
JSON.stringify({
error: "admin role required to install skills",
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const source = body.source as string
if (!source) {
return new Response(
JSON.stringify({ error: "source required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await installSkillAction(source)
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
})
}
case "toggle": {
if (auth.role !== "admin") {
return new Response(
JSON.stringify({ error: "admin role required" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const pluginId = body.pluginId as string
const enabled = body.enabled as boolean
if (!pluginId || enabled === undefined) {
return new Response(
JSON.stringify({
error: "pluginId and enabled required",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await toggleSkillAction(pluginId, enabled)
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
})
}
case "uninstall": {
if (auth.role !== "admin") {
return new Response(
JSON.stringify({ error: "admin role required" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const pluginId = body.pluginId as string
if (!pluginId) {
return new Response(
JSON.stringify({ error: "pluginId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await uninstallSkillAction(pluginId)
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
})
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Skills endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,337 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import {
getCustomThemes,
setUserThemePreference,
saveCustomTheme,
getCustomThemeById,
} from "@/app/actions/themes"
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
import type {
ThemeDefinition,
ColorMap,
ThemeFonts,
ThemeTokens,
ThemeShadows,
} from "@/lib/theme/types"
type ThemeAction = "list" | "set" | "generate" | "edit"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: { action: ThemeAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "list": {
const presets = THEME_PRESETS.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
isPreset: true,
}))
const customResult = await getCustomThemes()
const customs = customResult.success
? customResult.data.map((c) => ({
id: c.id,
name: c.name,
description: c.description,
isPreset: false,
}))
: []
return new Response(
JSON.stringify({ themes: [...presets, ...customs] }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "set": {
const themeId = body.themeId as string
if (!themeId) {
return new Response(
JSON.stringify({ error: "themeId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await setUserThemePreference(themeId)
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
success: true,
themeId,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "generate": {
const name = body.name as string
const description = body.description as string
const light = body.light as Record<string, string>
const dark = body.dark as Record<string, string>
const fonts = body.fonts as { sans: string; serif: string; mono: string }
const googleFonts = (body.googleFonts as string[]) ?? []
const radius = (body.radius as string) ?? "0.5rem"
const spacing = (body.spacing as string) ?? "0.25rem"
if (!name || !description || !light || !dark || !fonts) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const nativePreset = findPreset("native-compass")
if (!nativePreset) {
return new Response(
JSON.stringify({ error: "Internal error" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
const tokens: ThemeTokens = {
radius,
spacing,
trackingNormal: "0em",
shadowColor: "#000000",
shadowOpacity: "0.1",
shadowBlur: "3px",
shadowSpread: "0px",
shadowOffsetX: "0",
shadowOffsetY: "1px",
}
const defaultShadows: ThemeShadows = {
"2xs": "0 1px 3px 0px hsl(0 0% 0% / 0.05)",
xs: "0 1px 3px 0px hsl(0 0% 0% / 0.05)",
sm: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)",
default:
"0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)",
md: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10)",
lg: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10)",
xl: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10)",
"2xl": "0 1px 3px 0px hsl(0 0% 0% / 0.25)",
}
const theme: ThemeDefinition = {
id: "",
name,
description,
light: light as unknown as ColorMap,
dark: dark as unknown as ColorMap,
fonts: fonts as ThemeFonts,
fontSources: {
googleFonts,
},
tokens,
shadows: { light: defaultShadows, dark: defaultShadows },
isPreset: false,
previewColors: {
primary: light["primary"] ?? "oklch(0.5 0.1 200)",
background: light["background"] ?? "oklch(0.97 0 0)",
foreground: light["foreground"] ?? "oklch(0.2 0 0)",
},
}
const saveResult = await saveCustomTheme(
name,
description,
JSON.stringify(theme),
)
if (!saveResult.success) {
return new Response(
JSON.stringify({ error: saveResult.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const savedTheme = { ...theme, id: saveResult.id }
return new Response(
JSON.stringify({
success: true,
themeId: saveResult.id,
themeData: savedTheme,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "edit": {
const themeId = body.themeId as string
if (!themeId) {
return new Response(
JSON.stringify({ error: "themeId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const existing = await getCustomThemeById(themeId)
if (!existing.success) {
return new Response(
JSON.stringify({ error: existing.error }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
const prev = JSON.parse(
existing.data.themeData,
) as ThemeDefinition
const mergedLight = body.light
? ({
...prev.light,
...(body.light as Record<string, string>),
} as unknown as ColorMap)
: prev.light
const mergedDark = body.dark
? ({
...prev.dark,
...(body.dark as Record<string, string>),
} as unknown as ColorMap)
: prev.dark
const mergedFonts: ThemeFonts = body.fonts
? {
sans:
(body.fonts as { sans?: string }).sans ?? prev.fonts.sans,
serif:
(body.fonts as { serif?: string }).serif ??
prev.fonts.serif,
mono:
(body.fonts as { mono?: string }).mono ?? prev.fonts.mono,
}
: prev.fonts
const mergedTokens: ThemeTokens = {
...prev.tokens,
...(body.radius ? { radius: body.radius as string } : {}),
...(body.spacing ? { spacing: body.spacing as string } : {}),
}
const mergedFontSources = body.googleFonts
? { googleFonts: body.googleFonts as string[] }
: prev.fontSources
const name = (body.name as string) ?? existing.data.name
const description =
(body.description as string) ?? existing.data.description
const merged: ThemeDefinition = {
...prev,
id: themeId,
name,
description,
light: mergedLight,
dark: mergedDark,
fonts: mergedFonts,
fontSources: mergedFontSources,
tokens: mergedTokens,
previewColors: {
primary: mergedLight.primary,
background: mergedLight.background,
foreground: mergedLight.foreground,
},
}
const saveResult = await saveCustomTheme(
name,
description,
JSON.stringify(merged),
themeId,
)
if (!saveResult.success) {
return new Response(
JSON.stringify({ error: saveResult.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
success: true,
themeId,
themeData: merged,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Themes endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -1,7 +1,6 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { type UIMessage } from "ai"
import { useUIStream, type Spec } from "@json-render/react" import { useUIStream, type Spec } from "@json-render/react"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { import {
@ -11,8 +10,8 @@ import {
} from "@/app/actions/agent" } from "@/app/actions/agent"
import { getTextFromParts } from "@/lib/agent/chat-adapter" import { getTextFromParts } from "@/lib/agent/chat-adapter"
import { useCompassChat } from "@/hooks/use-compass-chat" import { useCompassChat } from "@/hooks/use-compass-chat"
import type { AgentMessage } from "@/lib/agent/message-types"
import { import {
WebSocketChatTransport,
detectBridge, detectBridge,
} from "@/lib/agent/ws-transport" } from "@/lib/agent/ws-transport"
import { import {
@ -47,11 +46,11 @@ export function useChatPanel(): PanelContextValue {
// --- Chat state context --- // --- Chat state context ---
interface ChatStateValue { interface ChatStateValue {
readonly messages: ReadonlyArray<UIMessage> readonly messages: ReadonlyArray<AgentMessage>
setMessages: ( setMessages: (
messages: messages:
| UIMessage[] | AgentMessage[]
| ((prev: UIMessage[]) => UIMessage[]) | ((prev: AgentMessage[]) => AgentMessage[])
) => void ) => void
sendMessage: (params: { text: string }) => void sendMessage: (params: { text: string }) => void
regenerate: () => void regenerate: () => void
@ -148,7 +147,7 @@ export function useAgentOptional(): PanelContextValue | null {
// --- Helper: extract generateUI output from parts --- // --- Helper: extract generateUI output from parts ---
function findGenerateUIOutput( function findGenerateUIOutput(
parts: ReadonlyArray<unknown>, parts: ReadonlyArray<AgentMessage["parts"][number]>,
dispatched: Set<string> dispatched: Set<string>
): { ): {
renderPrompt: string renderPrompt: string
@ -156,24 +155,13 @@ function findGenerateUIOutput(
callId: string callId: string
} | null { } | null {
for (const part of parts) { for (const part of parts) {
const p = part as Record<string, unknown> // only check tool-result parts
const pType = p.type as string | undefined if (part.type !== "tool-result") continue
// handle both static tool parts (tool-<name>) const callId = part.toolCallId
// and dynamic tool parts (dynamic-tool) if (dispatched.has(callId)) continue
const isToolPart =
typeof pType === "string" &&
(pType.startsWith("tool-") ||
pType === "dynamic-tool")
if (!isToolPart) continue
const state = p.state as string | undefined const output = part.result as
if (state !== "output-available") continue
const callId = p.toolCallId as string | undefined
if (!callId || dispatched.has(callId)) continue
const output = p.output as
| Record<string, unknown> | Record<string, unknown>
| undefined | undefined
if (output?.action !== "generateUI") continue if (output?.action !== "generateUI") continue
@ -239,10 +227,11 @@ export function ChatProvider({
} }
}, []) }, [])
// TODO: Re-implement bridge transport for new agent architecture
const bridgeTransport = React.useMemo(() => { const bridgeTransport = React.useMemo(() => {
if (bridge.bridgeConnected && bridge.bridgeEnabled) { // if (bridge.bridgeConnected && bridge.bridgeEnabled) {
return new WebSocketChatTransport() // return new WebSocketChatTransport()
} // }
return null return null
}, [bridge.bridgeConnected, bridge.bridgeEnabled]) }, [bridge.bridgeConnected, bridge.bridgeEnabled])
@ -256,14 +245,9 @@ export function ChatProvider({
const serialized = finalMessages.map((m) => ({ const serialized = finalMessages.map((m) => ({
id: m.id, id: m.id,
role: m.role, role: m.role,
content: getTextFromParts( content: getTextFromParts(m.parts),
m.parts as ReadonlyArray<{
type: string
text?: string
}>
),
parts: m.parts, parts: m.parts,
createdAt: new Date().toISOString(), createdAt: m.createdAt.toISOString(),
})) }))
await saveConversation(conversationId, serialized) await saveConversation(conversationId, serialized)
@ -344,7 +328,7 @@ export function ChatProvider({
if (!lastMsg || lastMsg.role !== "assistant") return if (!lastMsg || lastMsg.role !== "assistant") return
const result = findGenerateUIOutput( const result = findGenerateUIOutput(
lastMsg.parts as ReadonlyArray<unknown>, lastMsg.parts,
renderDispatchedRef.current renderDispatchedRef.current
) )
if (!result) return if (!result) return
@ -525,14 +509,15 @@ export function ChatProvider({
setConversationId(lastConv.id) setConversationId(lastConv.id)
const restored: UIMessage[] = msgResult.data.map( const restored: AgentMessage[] = msgResult.data.map(
(m) => ({ (m) => ({
id: m.id, id: m.id,
role: m.role as "user" | "assistant", role: m.role as "user" | "assistant",
parts: parts:
(m.parts as UIMessage["parts"]) ?? [ (m.parts as AgentMessage["parts"]) ?? [
{ type: "text" as const, text: m.content }, { type: "text" as const, text: m.content },
], ],
createdAt: m.createdAt ? new Date(m.createdAt) : new Date(),
}) })
) )
@ -541,15 +526,14 @@ export function ChatProvider({
// renders or navigate to /dashboard on resume // renders or navigate to /dashboard on resume
for (const m of restored) { for (const m of restored) {
if (m.role !== "assistant") continue if (m.role !== "assistant") continue
const parts = m.parts as ReadonlyArray<unknown>
let result = findGenerateUIOutput( let result = findGenerateUIOutput(
parts, m.parts,
renderDispatchedRef.current renderDispatchedRef.current
) )
while (result) { while (result) {
renderDispatchedRef.current.add(result.callId) renderDispatchedRef.current.add(result.callId)
result = findGenerateUIOutput( result = findGenerateUIOutput(
parts, m.parts,
renderDispatchedRef.current renderDispatchedRef.current
) )
} }

View File

@ -20,14 +20,7 @@ import {
IconAlertCircle, IconAlertCircle,
IconEye, IconEye,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { import type { AgentMessage } from "@/lib/agent/message-types"
isTextUIPart,
isToolUIPart,
isReasoningUIPart,
type UIMessage,
type ToolUIPart,
type DynamicToolUIPart,
} from "ai"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
Reasoning, Reasoning,
@ -176,20 +169,18 @@ function friendlyToolName(raw: string): string {
} }
interface ChatMessageProps { interface ChatMessageProps {
readonly msg: UIMessage readonly msg: AgentMessage
readonly copiedId: string | null readonly copiedId: string | null
readonly onCopy: (id: string, text: string) => void readonly onCopy: (id: string, text: string) => void
readonly onRegenerate: () => void readonly onRegenerate: () => void
readonly isStreaming?: boolean readonly isStreaming?: boolean
} }
type AnyToolPart = ToolUIPart | DynamicToolUIPart function extractToolName(part: AgentMessage["parts"][number]): string {
if (part.type === "tool-call") {
function extractToolName(part: AnyToolPart): string { return part.toolName
if (part.type === "dynamic-tool") {
return part.toolName ?? ""
} }
return part.type.slice(5) return ""
} }
// renders parts in their natural order from the AI SDK // renders parts in their natural order from the AI SDK
@ -203,8 +194,8 @@ const ChatMessage = memo(
}: ChatMessageProps) { }: ChatMessageProps) {
if (msg.role === "user") { if (msg.role === "user") {
const text = msg.parts const text = msg.parts
.filter(isTextUIPart) .filter((p) => p.type === "text")
.map((p) => p.text) .map((p) => (p as Extract<typeof p, { type: "text" }>).text)
.join("") .join("")
return ( return (
<Message from="user"> <Message from="user">
@ -264,19 +255,19 @@ const ChatMessage = memo(
for (let i = 0; i < msg.parts.length; i++) { for (let i = 0; i < msg.parts.length; i++) {
const part = msg.parts[i] const part = msg.parts[i]
if (isReasoningUIPart(part)) { if (part.type === "reasoning") {
pendingReasoning += part.text pendingReasoning += part.text
reasoningStreaming = part.state === "streaming" reasoningStreaming = part.state === "streaming"
continue continue
} }
if (isTextUIPart(part)) { if (part.type === "text") {
pendingText += part.text pendingText += part.text
allText += part.text allText += part.text
continue continue
} }
if (isToolUIPart(part)) { if (part.type === "tool-call") {
sawToolPart = true sawToolPart = true
// flush reasoning accumulated before this tool // flush reasoning accumulated before this tool
flushThinking(pendingReasoning, i, reasoningStreaming) flushThinking(pendingReasoning, i, reasoningStreaming)
@ -284,24 +275,45 @@ const ChatMessage = memo(
reasoningStreaming = false reasoningStreaming = false
// flush text as thinking (not final) // flush text as thinking (not final)
flushText(i, false) flushText(i, false)
const tp = part as AnyToolPart const rawName = part.toolName
const rawName = extractToolName(tp)
// map our state to the expected Tool component state
const toolState =
part.state === "partial-call"
? "partial-call"
: part.state === "call"
? "call"
: "result"
// find matching result for this tool call
const resultPart = msg.parts.find(
(p) =>
p.type === "tool-result" &&
p.toolCallId === part.toolCallId
) as Extract<
AgentMessage["parts"][number],
{ type: "tool-result" }
> | undefined
elements.push( elements.push(
<Tool key={tp.toolCallId}> <Tool key={`tool-${part.toolCallId}`}>
<ToolHeader <ToolHeader
title={ title={
friendlyToolName(rawName) || "Working" friendlyToolName(rawName) || "Working"
} }
type={tp.type as ToolUIPart["type"]} type={"tool-call" as const}
state={tp.state} state={toolState}
/> />
<ToolContent> <ToolContent>
<ToolInput input={tp.input} /> <ToolInput input={part.args} />
{(tp.state === "output-available" || {resultPart && (
tp.state === "output-error") && (
<ToolOutput <ToolOutput
output={tp.output} output={resultPart.result}
errorText={tp.errorText} errorText={
resultPart.isError
? String(resultPart.result)
: undefined
}
/> />
)} )}
</ToolContent> </ToolContent>
@ -317,20 +329,6 @@ const ChatMessage = memo(
reasoningStreaming reasoningStreaming
) )
// while streaming, if no tool calls have arrived yet
// and text is substantial, it's likely chain-of-thought
// that'll be reclassified as thinking once tools come in.
// render it collapsed so it doesn't flood the screen.
const COT_THRESHOLD = 500
if (
msgStreaming &&
!sawToolPart &&
pendingText.length > COT_THRESHOLD
) {
flushThinking(pendingText, msg.parts.length, true)
pendingText = ""
}
// flush remaining text as the final response // flush remaining text as the final response
flushText(msg.parts.length, true) flushText(msg.parts.length, true)

View File

@ -1,439 +1,282 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { import { ChevronDown, Check } from "lucide-react"
ChevronDown,
Check,
Search,
Loader2,
Zap,
} from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { import { Input } from "@/components/ui/input"
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ProviderIcon, hasLogo } from "./provider-icon"
import { useBridgeState } from "./chat-provider"
import {
getActiveModel,
getModelList,
getUserModelPreference,
setUserModelPreference,
} from "@/app/actions/ai-config"
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next" // ============================================================================
const DEFAULT_MODEL_NAME = "Qwen3 Coder" // Inline Claude sparkle — rendered directly here to avoid stale HMR
const DEFAULT_PROVIDER = "Alibaba (Qwen)" // from provider-icon.tsx. This is the ONLY icon the model dropdown needs.
// ============================================================================
// anthropic models available through the bridge function ClaudeSparkle({ size = 14 }: { size?: number }): React.JSX.Element {
const BRIDGE_MODELS = [ return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path
d="M12 2a.9.9 0 0 1 .84.58l2.32 5.94a4.5 4.5 0 0 0 2.6 2.6l5.94 2.32a.9.9 0 0 1 0 1.67l-5.94 2.32a4.5 4.5 0 0 0-2.6 2.6l-2.32 5.94a.9.9 0 0 1-1.68 0l-2.32-5.94a4.5 4.5 0 0 0-2.6-2.6L.3 15.11a.9.9 0 0 1 0-1.67l5.94-2.32a4.5 4.5 0 0 0 2.6-2.6L11.16 2.58A.9.9 0 0 1 12 2Z"
fill="#D97757"
/>
</svg>
)
}
// ============================================================================
// Types
// ============================================================================
const PROVIDER_TYPES = [
"anthropic-oauth",
"anthropic-key",
"openrouter",
"ollama",
"custom",
] as const
type ProviderType = (typeof PROVIDER_TYPES)[number]
const AGENT_MODELS = [
{ {
id: "claude-sonnet-4-5-20250929", id: "sonnet",
name: "Claude Sonnet 4.5", name: "Sonnet",
provider: "Anthropic", description: "Fast and capable",
}, },
{ {
id: "claude-opus-4-6", id: "opus",
name: "Claude Opus 4.6", name: "Opus",
provider: "Anthropic", description: "Most intelligent",
}, },
{ {
id: "claude-haiku-4-5-20251001", id: "haiku",
name: "Claude Haiku 4.5", name: "Haiku",
provider: "Anthropic", description: "Quick and lightweight",
}, },
] as const ] as const
const DEFAULT_BRIDGE_MODEL = BRIDGE_MODELS[0] type AgentModel = (typeof AGENT_MODELS)[number]
// --- shared state so all instances stay in sync --- interface ProviderState {
providerType: ProviderType
interface SharedState { model: AgentModel
readonly display: { customModelId: string
readonly id: string
readonly name: string
readonly provider: string
}
readonly global: {
readonly id: string
readonly name: string
readonly provider: string
}
readonly bridgeModel: {
readonly id: string
readonly name: string
readonly provider: string
}
readonly allowUserSelection: boolean
readonly isAdmin: boolean
readonly maxCostPerMillion: string | null
readonly configLoaded: boolean
} }
let shared: SharedState = { // ============================================================================
display: { // Provider display helpers
id: DEFAULT_MODEL_ID, // ============================================================================
name: DEFAULT_MODEL_NAME,
provider: DEFAULT_PROVIDER, function providerUsesModelPicker(type: ProviderType): boolean {
}, return (
global: { type === "anthropic-oauth" ||
id: DEFAULT_MODEL_ID, type === "anthropic-key" ||
name: DEFAULT_MODEL_NAME, type === "openrouter"
provider: DEFAULT_PROVIDER, )
},
bridgeModel: {
id: DEFAULT_BRIDGE_MODEL.id,
name: DEFAULT_BRIDGE_MODEL.name,
provider: DEFAULT_BRIDGE_MODEL.provider,
},
allowUserSelection: true,
isAdmin: false,
maxCostPerMillion: null,
configLoaded: false,
} }
// ============================================================================
// External store (shared across components)
// ============================================================================
const STORAGE_KEY = "compass-agent-model"
const PROVIDER_STORAGE_KEY = "compass-agent-provider"
function loadState(): ProviderState {
if (typeof window === "undefined") {
return defaultState()
}
try {
const raw = localStorage.getItem(PROVIDER_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, unknown>
const providerType = (
PROVIDER_TYPES.includes(parsed.providerType as ProviderType)
? parsed.providerType
: "anthropic-oauth"
) as ProviderType
const modelObj = parsed.model as
| { id?: string }
| undefined
const model =
AGENT_MODELS.find((m) => m.id === modelObj?.id) ??
AGENT_MODELS[0]
return {
providerType,
model,
customModelId:
typeof parsed.customModelId === "string"
? parsed.customModelId
: "",
}
}
} catch {
// fall through
}
// legacy: migrate from old model-only storage
const legacyModel = localStorage.getItem(STORAGE_KEY)
if (legacyModel) {
const model =
AGENT_MODELS.find((m) => m.id === legacyModel) ??
AGENT_MODELS[0]
return { ...defaultState(), model }
}
return defaultState()
}
function defaultState(): ProviderState {
return {
providerType: "anthropic-oauth",
model: AGENT_MODELS[0],
customModelId: "",
}
}
let state: ProviderState = defaultState()
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
function getSnapshot(): SharedState { function subscribe(listener: () => void): () => void {
return shared
}
function setShared(
next: Partial<SharedState>
): void {
shared = { ...shared, ...next }
for (const l of listeners) l()
}
function subscribe(
listener: () => void
): () => void {
listeners.add(listener) listeners.add(listener)
return () => { return () => {
listeners.delete(listener) listeners.delete(listener)
} }
} }
interface ModelInfo { function getSnapshot(): ProviderState {
readonly id: string return state
readonly name: string
readonly provider: string
readonly contextLength: number
readonly promptCost: string
readonly completionCost: string
} }
interface ProviderGroup { function getServerSnapshot(): ProviderState {
readonly provider: string return defaultState()
readonly models: ReadonlyArray<ModelInfo>
} }
function outputCostPerMillion( function emit(): void {
completionCost: string for (const l of listeners) l()
): number {
return parseFloat(completionCost) * 1_000_000
} }
function formatOutputCost( function persistState(): void {
completionCost: string try {
): string { localStorage.setItem(
const cost = outputCostPerMillion(completionCost) PROVIDER_STORAGE_KEY,
if (cost === 0) return "free" JSON.stringify(state)
if (cost < 0.01) return "<$0.01/M" )
return `$${cost.toFixed(2)}/M` localStorage.setItem(STORAGE_KEY, state.model.id)
} catch {
// storage full or unavailable
} }
}
function updateState(patch: Partial<ProviderState>): void {
state = { ...state, ...patch }
persistState()
emit()
}
/**
* Update provider type from settings page.
* Called by ai-model-tab when the user changes provider.
*/
export function setProviderType(type: ProviderType): void {
updateState({ providerType: type })
}
// ============================================================================
// Public API for use-agent.ts
// ============================================================================
/** Returns the model ID to send to the agent server */
export function getAgentModelId(): string {
if (
state.providerType === "ollama" ||
state.providerType === "custom"
) {
return state.customModelId || state.model.id
}
return state.model.id
}
/** Returns the provider type for context */
export function getAgentProviderType(): ProviderType {
return state.providerType
}
// ============================================================================
// Component
// ============================================================================
export function ModelDropdown(): React.JSX.Element { export function ModelDropdown(): React.JSX.Element {
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
const state = React.useSyncExternalStore( const current = React.useSyncExternalStore(
subscribe, subscribe,
getSnapshot, getSnapshot,
getSnapshot getServerSnapshot
) )
const [groups, setGroups] = React.useState<
ReadonlyArray<ProviderGroup>
>([])
const [loading, setLoading] = React.useState(false)
const [search, setSearch] = React.useState("")
const [saving, setSaving] = React.useState<
string | null
>(null)
const [listLoaded, setListLoaded] =
React.useState(false)
const [activeProvider, setActiveProvider] =
React.useState<string | null>(null)
const bridge = useBridgeState()
const bridgeActive =
bridge.bridgeConnected && bridge.bridgeEnabled
// restore from localStorage on mount
React.useEffect(() => { React.useEffect(() => {
if (state.configLoaded) return const stored = loadState()
setShared({ configLoaded: true })
// restore bridge model preference from localStorage
const storedBridge = localStorage.getItem(
"compass-bridge-model"
)
if (storedBridge) {
const found = BRIDGE_MODELS.find(
(m) => m.id === storedBridge
)
if (found) {
setShared({
bridgeModel: {
id: found.id,
name: found.name,
provider: found.provider,
},
})
}
}
Promise.all([
getActiveModel(),
getUserModelPreference(),
]).then(([configResult, prefResult]) => {
let gModelId = DEFAULT_MODEL_ID
let gModelName = DEFAULT_MODEL_NAME
let gProvider = DEFAULT_PROVIDER
let canSelect = true
let ceiling: string | null = null
let admin = false
if (configResult.success && configResult.data) {
gModelId = configResult.data.modelId
gModelName = configResult.data.modelName
gProvider = configResult.data.provider
canSelect =
configResult.data.allowUserSelection
ceiling =
configResult.data.maxCostPerMillion
admin = configResult.data.isAdmin
}
const base: Partial<SharedState> = {
global: {
id: gModelId,
name: gModelName,
provider: gProvider,
},
allowUserSelection: canSelect,
isAdmin: admin,
maxCostPerMillion: ceiling,
}
if ( if (
canSelect && stored.providerType !== state.providerType ||
prefResult.success && stored.model.id !== state.model.id
prefResult.data
) { ) {
const prefValid = state = stored
ceiling === null || emit()
outputCostPerMillion( }
prefResult.data.completionCost }, [])
) <= parseFloat(ceiling)
if (prefValid) { // hydrate provider type from D1 on mount
const slashIdx = React.useEffect(() => {
prefResult.data.modelId.indexOf("/") import("@/app/actions/provider-config")
setShared({ .then(({ getUserProviderConfig }) => {
...base, getUserProviderConfig()
display: { .then((result) => {
id: prefResult.data.modelId, if (!("success" in result) || !result.success)
name:
slashIdx > 0
? prefResult.data.modelId.slice(
slashIdx + 1
)
: prefResult.data.modelId,
provider: "",
},
})
return return
} if (!result.data) return
}
setShared({ const d = result.data
...base, const providerType = (
display: { PROVIDER_TYPES.includes(
id: gModelId, d.providerType as ProviderType
name: gModelName, )
provider: gProvider, ? d.providerType
}, : state.providerType
) as ProviderType
if (providerType !== state.providerType) {
updateState({ providerType })
}
}) })
.catch(() => {})
}) })
}, [state.configLoaded]) .catch(() => {})
}, [])
React.useEffect(() => { const usesModelPicker = providerUsesModelPicker(
if (!open || listLoaded || bridgeActive) return current.providerType
setLoading(true)
getModelList().then((result) => {
if (result.success) {
const sorted = [...result.data]
.sort((a, b) => {
const aHas = hasLogo(a.provider) ? 0 : 1
const bHas = hasLogo(b.provider) ? 0 : 1
if (aHas !== bHas) return aHas - bHas
return a.provider.localeCompare(
b.provider
) )
})
.map((g) => ({
...g,
models: [...g.models].sort(
(a, b) =>
outputCostPerMillion(
a.completionCost
) -
outputCostPerMillion(
b.completionCost
)
),
}))
setGroups(sorted)
}
setListLoaded(true)
setLoading(false)
})
}, [open, listLoaded, bridgeActive])
// reset provider filter when popover closes const displayName = usesModelPicker
React.useEffect(() => { ? current.model.name
if (!open) { : current.customModelId || "Custom"
setActiveProvider(null)
setSearch("")
}
}, [open])
const query = search.toLowerCase() const handleModelSelect = (m: AgentModel): void => {
const ceiling = state.maxCostPerMillion updateState({ model: m })
? parseFloat(state.maxCostPerMillion)
: null
const filtered = React.useMemo(() => {
return groups
.map((g) => ({
...g,
models: g.models.filter((m) => {
if (ceiling !== null) {
if (
outputCostPerMillion(
m.completionCost
) > ceiling
)
return false
}
if (
activeProvider &&
g.provider !== activeProvider
) {
return false
}
if (!query) return true
return (
m.name.toLowerCase().includes(query) ||
m.id.toLowerCase().includes(query)
)
}),
}))
.filter((g) => g.models.length > 0)
}, [groups, query, ceiling, activeProvider])
const totalFiltered = React.useMemo(() => {
let count = 0
for (const g of groups) {
for (const m of g.models) {
if (
ceiling === null ||
outputCostPerMillion(m.completionCost) <=
ceiling
) {
count++
}
}
}
return count
}, [groups, ceiling])
// sorted groups for provider sidebar (cost-filtered)
const sortedGroups = React.useMemo(() => {
return groups
.map((g) => ({
...g,
models: g.models.filter((m) => {
if (ceiling === null) return true
return (
outputCostPerMillion(
m.completionCost
) <= ceiling
)
}),
}))
.filter((g) => g.models.length > 0)
}, [groups, ceiling])
const handleSelect = async (
model: ModelInfo
): Promise<void> => {
if (model.id === state.display.id) {
setOpen(false)
return
}
setSaving(model.id)
const result = await setUserModelPreference(
model.id,
model.promptCost,
model.completionCost
)
setSaving(null)
if (result.success) {
setShared({
display: {
id: model.id,
name: model.name,
provider: model.provider,
},
})
toast.success(`Switched to ${model.name}`)
setOpen(false)
} else {
toast.error(result.error ?? "Failed to switch")
}
}
const handleBridgeModelSelect = (
model: typeof BRIDGE_MODELS[number]
): void => {
setShared({
bridgeModel: {
id: model.id,
name: model.name,
provider: model.provider,
},
})
localStorage.setItem(
"compass-bridge-model",
model.id
)
toast.success(`Bridge model: ${model.name}`)
setOpen(false) setOpen(false)
} }
// bridge active: show bridge model selector const handleCustomModelChange = (v: string): void => {
if (bridgeActive) { updateState({ customModelId: v })
}
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -442,13 +285,13 @@ export function ModelDropdown(): React.JSX.Element {
className={cn( className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs", "flex items-center gap-1.5 rounded-md px-2 py-1 text-xs",
"hover:bg-muted hover:text-foreground transition-colors", "hover:bg-muted hover:text-foreground transition-colors",
"text-emerald-600 dark:text-emerald-400", "text-muted-foreground",
open && "bg-muted text-foreground" open && "bg-muted text-foreground"
)} )}
> >
<Zap className="h-3 w-3" /> <ClaudeSparkle size={14} />
<span className="max-w-32 truncate"> <span className="max-w-36 truncate">
{state.bridgeModel.name} {displayName}
</span> </span>
<ChevronDown className="h-3 w-3 opacity-50" /> <ChevronDown className="h-3 w-3 opacity-50" />
</button> </button>
@ -456,254 +299,57 @@ export function ModelDropdown(): React.JSX.Element {
<PopoverContent <PopoverContent
align="start" align="start"
side="top" side="top"
className="w-64 p-1" className="w-56 p-1"
> >
<div className="px-2 py-1.5 mb-1"> {usesModelPicker ? (
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"> <div role="radiogroup" aria-label="Model">
Claude Code Bridge {AGENT_MODELS.map((m) => {
</p> const isActive = m.id === current.model.id
</div>
{BRIDGE_MODELS.map((model) => {
const isActive =
model.id === state.bridgeModel.id
return ( return (
<button <button
key={model.id} key={m.id}
type="button" type="button"
onClick={() => role="radio"
handleBridgeModelSelect(model) aria-checked={isActive}
} onClick={() => handleModelSelect(m)}
className={cn( className={cn(
"w-full text-left rounded-lg px-2.5 py-2 flex items-center gap-2.5 transition-all", "w-full text-left rounded-md px-2.5 py-2 flex items-center gap-2.5 transition-all",
isActive isActive
? "bg-primary/10 ring-1 ring-primary/30" ? "bg-primary/10 ring-1 ring-primary/30"
: "hover:bg-muted/70" : "hover:bg-muted/70"
)} )}
> >
<ProviderIcon <div className="flex-1 min-w-0">
provider="Anthropic" <span className="text-xs font-medium">
size={20} {m.name}
className="shrink-0"
/>
<span className="text-xs font-medium flex-1">
{model.name}
</span> </span>
<p className="text-[10px] text-muted-foreground">
{m.description}
</p>
</div>
{isActive && ( {isActive && (
<Check className="h-3 w-3 text-primary shrink-0" /> <Check className="h-3 w-3 text-primary shrink-0" />
)} )}
</button> </button>
) )
})} })}
</PopoverContent>
</Popover>
)
}
if (!state.allowUserSelection && !state.isAdmin) {
return (
<div className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground">
<ProviderIcon
provider={state.global.provider}
size={14}
/>
<span className="max-w-28 truncate">
{state.global.name}
</span>
</div>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground",
"hover:bg-muted hover:text-foreground transition-colors",
open && "bg-muted text-foreground"
)}
>
<ProviderIcon
provider={state.display.provider}
size={14}
/>
<span className="max-w-28 truncate">
{state.display.name}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-96 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<TooltipProvider delayDuration={200}>
{/* search */}
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) =>
setSearch(e.target.value)
}
placeholder="Search models..."
className="h-8 pl-8 text-xs"
/>
</div>
</div>
{/* two-panel layout */}
<div className="flex h-72">
{/* provider sidebar */}
<div className="w-11 shrink-0 overflow-y-auto flex flex-col items-center gap-0.5 py-1 border-r">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setActiveProvider(null)
}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-semibold transition-all shrink-0",
activeProvider === null
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-muted"
)}
>
All
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">
All providers
</p>
</TooltipContent>
</Tooltip>
{sortedGroups.map((group) => (
<Tooltip key={group.provider}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setActiveProvider(
activeProvider ===
group.provider
? null
: group.provider
)
}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center transition-all shrink-0",
activeProvider ===
group.provider
? "bg-primary/15 scale-110"
: "hover:bg-muted"
)}
>
<ProviderIcon
provider={group.provider}
size={18}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">
{group.provider} (
{group.models.length})
</p>
</TooltipContent>
</Tooltip>
))}
</div>
{/* model list */}
<div className="flex-1 overflow-y-auto p-1">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
No models found.
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="p-1.5">
{filtered.map((group) => <label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1 block">
group.models.map((model) => { Model ID
const isActive = </label>
model.id === state.display.id <Input
const isSaving = type="text"
saving === model.id value={current.customModelId}
onChange={(e) =>
return ( handleCustomModelChange(e.target.value)
<button
key={model.id}
type="button"
disabled={
isSaving ||
saving !== null
} }
onClick={() => placeholder="llama3.2"
handleSelect(model) className="h-7 text-xs"
}
className={cn(
"w-full text-left rounded-lg px-2.5 py-2 flex items-center gap-2.5 transition-all",
isActive
? "bg-primary/10 ring-1 ring-primary/30"
: "hover:bg-muted/70",
isSaving && "opacity-50"
)}
>
<ProviderIcon
provider={
model.provider
}
size={20}
className="shrink-0"
/> />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium truncate">
{model.name}
</span>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
) : isActive ? (
<Check className="h-3 w-3 text-primary shrink-0" />
) : null}
</div>
<Badge
variant="secondary"
className="text-[10px] px-1 py-0 h-3.5 mt-0.5 font-normal"
>
{formatOutputCost(
model.completionCost
)}
</Badge>
</div>
</button>
)
})
)}
</div> </div>
)} )}
</div>
</div>
{/* budget footer */}
{ceiling !== null && listLoaded && (
<div className="border-t px-3 py-1.5">
<p className="text-[10px] text-muted-foreground">
{totalFiltered} models within $
{state.maxCostPerMillion}/M budget
</p>
</div>
)}
</TooltipProvider>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )

View File

@ -2,9 +2,31 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Inline SVG for Claude sparkle — avoids static file caching issues
function ClaudeIcon({ size, className }: { size: number; className?: string }): React.JSX.Element {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={className}
>
<path
d="M12 2a.9.9 0 0 1 .84.58l2.32 5.94a4.5 4.5 0 0 0 2.6 2.6l5.94 2.32a.9.9 0 0 1 0 1.67l-5.94 2.32a4.5 4.5 0 0 0-2.6 2.6l-2.32 5.94a.9.9 0 0 1-1.68 0l-2.32-5.94a4.5 4.5 0 0 0-2.6-2.6L.3 15.11a.9.9 0 0 1 0-1.67l5.94-2.32a4.5 4.5 0 0 0 2.6-2.6L11.16 2.58A.9.9 0 0 1 12 2Z"
fill="#D97757"
/>
</svg>
)
}
// providers with inline SVG components (preferred) or file-based logos
const INLINE_ICONS: Record<string, (size: number, className?: string) => React.JSX.Element> = {
Anthropic: (size, className) => <ClaudeIcon size={size} className={className} />,
}
// provider logo files in /public/providers/ // provider logo files in /public/providers/
export const PROVIDER_LOGO: Record<string, string> = { export const PROVIDER_LOGO: Record<string, string> = {
Anthropic: "anthropic",
OpenAI: "openai", OpenAI: "openai",
Google: "google", Google: "google",
Meta: "meta", Meta: "meta",
@ -15,6 +37,8 @@ export const PROVIDER_LOGO: Record<string, string> = {
Microsoft: "microsoft", Microsoft: "microsoft",
Amazon: "amazon", Amazon: "amazon",
Perplexity: "perplexity", Perplexity: "perplexity",
Ollama: "ollama",
OpenRouter: "openai",
} }
const PROVIDER_ABBR: Record<string, string> = { const PROVIDER_ABBR: Record<string, string> = {
@ -31,7 +55,7 @@ function getProviderAbbr(name: string): string {
} }
export function hasLogo(provider: string): boolean { export function hasLogo(provider: string): boolean {
return provider in PROVIDER_LOGO return provider in PROVIDER_LOGO || provider in INLINE_ICONS
} }
export function ProviderIcon({ export function ProviderIcon({
@ -43,8 +67,13 @@ export function ProviderIcon({
readonly size?: number readonly size?: number
readonly className?: string readonly className?: string
}): React.JSX.Element { }): React.JSX.Element {
const logo = PROVIDER_LOGO[provider] // Prefer inline SVG components
const inlineIcon = INLINE_ICONS[provider]
if (inlineIcon) {
return inlineIcon(size, className)
}
const logo = PROVIDER_LOGO[provider]
if (logo) { if (logo) {
return ( return (
<img <img

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { UIMessage } from "ai" import type { UIMessage } from "./types"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import type { ComponentProps, HTMLAttributes, ReactElement } from "react" import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
import { createContext, useContext, useEffect, useState } from "react" import { createContext, useContext, useEffect, useState } from "react"

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { ToolUIPart } from "ai" import type { ToolUIPart } from "./types"
import { CheckIcon, XIcon } from "lucide-react" import { CheckIcon, XIcon } from "lucide-react"
import { type ComponentProps, createContext, type ReactNode, useContext } from "react" import { type ComponentProps, createContext, type ReactNode, useContext } from "react"
import { Alert, AlertDescription } from "@/components/ui/alert" import { Alert, AlertDescription } from "@/components/ui/alert"

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { LanguageModelUsage } from "ai" import type { LanguageModelUsage } from "./types"
import { type ComponentProps, createContext, useContext } from "react" import { type ComponentProps, createContext, useContext } from "react"
import { getUsage } from "tokenlens" import { getUsage } from "tokenlens"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"

View File

@ -1,7 +1,7 @@
import type { Experimental_GeneratedImage } from "ai" import type { GeneratedImage } from "./types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type ImageProps = Experimental_GeneratedImage & { export type ImageProps = GeneratedImage & {
className?: string className?: string
alt?: string alt?: string
} }

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { FileUIPart, UIMessage } from "ai" import type { FileUIPart, UIMessage } from "./types"
import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from "lucide-react" import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from "lucide-react"
import type { ComponentProps, HTMLAttributes, ReactElement } from "react" import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react" import { createContext, memo, useContext, useEffect, useState } from "react"

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { ChatStatus, FileUIPart } from "ai" import type { ChatStatus, FileUIPart } from "./types"
import { import {
CornerDownLeftIcon, CornerDownLeftIcon,
ImageIcon, ImageIcon,

View File

@ -1,6 +1,15 @@
"use client" "use client"
import type { ToolUIPart } from "ai" // Tool state types (no longer dependent on AI SDK)
type ToolState =
| "input-streaming"
| "input-available"
| "output-available"
| "output-error"
| "output-denied"
| "partial-call"
| "call"
| "result"
import { import {
CheckCircleIcon, CheckCircleIcon,
ChevronDownIcon, ChevronDownIcon,
@ -21,12 +30,12 @@ export const Tool = ({ className, ...props }: ToolProps) => (
export interface ToolHeaderProps { export interface ToolHeaderProps {
title?: string title?: string
type: ToolUIPart["type"] type: string
state: ToolUIPart["state"] state: ToolState
className?: string className?: string
} }
const getStatusIcon = (status: ToolUIPart["state"]): ReactNode => { const getStatusIcon = (status: ToolState): ReactNode => {
switch (status) { switch (status) {
case "input-streaming": case "input-streaming":
case "input-available": case "input-available":
@ -41,8 +50,11 @@ const getStatusIcon = (status: ToolUIPart["state"]): ReactNode => {
} }
} }
const isInProgress = (status: ToolUIPart["state"]): boolean => const isInProgress = (status: ToolState): boolean =>
status === "input-streaming" || status === "input-available" status === "input-streaming" ||
status === "input-available" ||
status === "partial-call" ||
status === "call"
export const ToolHeader = ({ className, title, type, state, ...props }: ToolHeaderProps) => ( export const ToolHeader = ({ className, title, type, state, ...props }: ToolHeaderProps) => (
<CollapsibleTrigger <CollapsibleTrigger
@ -74,7 +86,7 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
) )
export type ToolInputProps = ComponentProps<"div"> & { export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"] input: unknown
} }
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => ( export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
@ -89,8 +101,8 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
) )
export type ToolOutputProps = ComponentProps<"div"> & { export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"] output: unknown
errorText: ToolUIPart["errorText"] errorText?: string
} }
export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => { export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => {

View File

@ -0,0 +1,57 @@
/**
* Local type definitions that replace AI SDK type imports.
* These match the shapes the shadcn AI components actually use.
*/
export type ChatStatus =
| "streaming"
| "submitted"
| "ready"
| "error"
export interface LanguageModelUsage {
readonly inputTokens?: number
readonly outputTokens?: number
readonly totalTokens?: number
readonly cachedInputTokens?: number
readonly reasoningTokens?: number
readonly inputTokenDetails?: {
readonly noCacheTokens?: number
readonly cacheReadTokens?: number
readonly cacheWriteTokens?: number
}
readonly outputTokenDetails?: {
readonly textTokens?: number
readonly reasoningTokens?: number
}
}
export interface FileUIPart {
readonly type: "file"
readonly name?: string
readonly filename?: string
readonly mediaType: string
readonly url: string
}
export interface ToolUIPart {
readonly type: string
readonly toolCallId: string
readonly toolName: string
readonly args: unknown
readonly state: string
readonly result?: unknown
}
export interface UIMessage {
readonly id: string
readonly role: "user" | "assistant" | "system"
readonly parts: ReadonlyArray<Record<string, unknown>>
readonly createdAt?: Date
}
export interface GeneratedImage {
readonly base64?: string
readonly uint8Array?: Uint8Array
readonly mediaType?: string
}

View File

@ -5,6 +5,9 @@ import {
Check, Check,
Loader2, Loader2,
Search, Search,
Eye,
EyeOff,
X,
} from "lucide-react" } from "lucide-react"
import { import {
Bar, Bar,
@ -39,6 +42,11 @@ import {
getUsageMetrics, getUsageMetrics,
updateModelPolicy, updateModelPolicy,
} from "@/app/actions/ai-config" } from "@/app/actions/ai-config"
import {
getUserProviderConfig,
setUserProviderConfig,
clearUserProviderConfig,
} from "@/app/actions/provider-config"
import { Slider } from "@/components/ui/slider" import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -46,6 +54,11 @@ import {
ProviderIcon, ProviderIcon,
hasLogo, hasLogo,
} from "@/components/agent/provider-icon" } from "@/components/agent/provider-icon"
import { setProviderType } from "@/components/agent/model-dropdown"
// ============================================================================
// Types
// ============================================================================
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next" const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
@ -93,6 +106,79 @@ interface UsageMetrics {
}> }>
} }
// ============================================================================
// Provider types
// ============================================================================
const PROVIDER_TYPES = [
"anthropic-oauth",
"anthropic-key",
"openrouter",
"ollama",
"custom",
] as const
type ProviderType = (typeof PROVIDER_TYPES)[number]
interface ProviderInfo {
readonly type: ProviderType
readonly label: string
readonly icon: string
readonly description: string
readonly needsApiKey: boolean
readonly needsBaseUrl: boolean
readonly defaultBaseUrl?: string
}
const PROVIDERS: ReadonlyArray<ProviderInfo> = [
{
type: "anthropic-oauth",
label: "Anthropic",
icon: "Anthropic",
description: "Uses CLI OAuth credentials",
needsApiKey: false,
needsBaseUrl: false,
},
{
type: "anthropic-key",
label: "Anthropic (API Key)",
icon: "Anthropic",
description: "Direct API access with your key",
needsApiKey: true,
needsBaseUrl: false,
},
{
type: "openrouter",
label: "OpenRouter",
icon: "OpenRouter",
description: "Multi-provider routing",
needsApiKey: true,
needsBaseUrl: false,
defaultBaseUrl: "https://openrouter.ai/api",
},
{
type: "ollama",
label: "Ollama",
icon: "Ollama",
description: "Local inference",
needsApiKey: false,
needsBaseUrl: true,
defaultBaseUrl: "http://localhost:11434",
},
{
type: "custom",
label: "Custom",
icon: "Custom",
description: "Any OpenAI-compatible endpoint",
needsApiKey: true,
needsBaseUrl: true,
},
]
// ============================================================================
// Helpers
// ============================================================================
function formatCost(costPerToken: string): string { function formatCost(costPerToken: string): string {
const perMillion = const perMillion =
parseFloat(costPerToken) * 1_000_000 parseFloat(costPerToken) * 1_000_000
@ -134,7 +220,265 @@ function outputCostPerMillion(
return parseFloat(completionCost) * 1_000_000 return parseFloat(completionCost) * 1_000_000
} }
// --- two-panel model picker --- // ============================================================================
// Provider Configuration Section
// ============================================================================
function ProviderConfigSection({
onProviderChanged,
}: {
readonly onProviderChanged: () => void
}): React.JSX.Element {
const [activeType, setActiveType] =
React.useState<ProviderType>("anthropic-oauth")
const [apiKey, setApiKey] = React.useState("")
const [baseUrl, setBaseUrl] = React.useState("")
const [showKey, setShowKey] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const [loading, setLoading] = React.useState(true)
const [hasStoredKey, setHasStoredKey] =
React.useState(false)
// load current config from D1
React.useEffect(() => {
getUserProviderConfig()
.then((result) => {
if (
"success" in result &&
result.success &&
result.data
) {
const d = result.data
const type = (
PROVIDER_TYPES.includes(
d.providerType as ProviderType
)
? d.providerType
: "anthropic-oauth"
) as ProviderType
setActiveType(type)
setBaseUrl(d.baseUrl ?? "")
setHasStoredKey(d.hasApiKey)
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const info = PROVIDERS.find(
(p) => p.type === activeType
) ?? PROVIDERS[0]
const handleProviderSelect = (
type: ProviderType
): void => {
setActiveType(type)
setApiKey("")
setShowKey(false)
const newInfo = PROVIDERS.find(
(p) => p.type === type
)
setBaseUrl(newInfo?.defaultBaseUrl ?? "")
setHasStoredKey(false)
}
const handleSave = async (): Promise<void> => {
setSaving(true)
const result = await setUserProviderConfig(
activeType,
apiKey || undefined,
baseUrl || undefined
)
setSaving(false)
if (result.success) {
toast.success("Provider updated")
setProviderType(activeType)
setHasStoredKey(Boolean(apiKey))
setApiKey("")
onProviderChanged()
} else {
toast.error(result.error ?? "Failed to save")
}
}
const handleClear = async (): Promise<void> => {
setSaving(true)
const result = await clearUserProviderConfig()
setSaving(false)
if (result.success) {
toast.success("Reverted to default")
setActiveType("anthropic-oauth")
setApiKey("")
setBaseUrl("")
setHasStoredKey(false)
setProviderType("anthropic-oauth")
onProviderChanged()
} else {
toast.error(result.error ?? "Failed to clear")
}
}
if (loading) {
return (
<div className="space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-9 w-full" />
</div>
)
}
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">AI Provider</Label>
<p className="text-muted-foreground text-xs">
Choose where your AI inference runs.
</p>
</div>
{/* provider pills */}
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label="AI Provider"
>
{PROVIDERS.map((p) => {
const isActive = p.type === activeType
return (
<button
key={p.type}
type="button"
role="radio"
aria-checked={isActive}
onClick={() =>
handleProviderSelect(p.type)
}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all",
isActive
? "bg-primary/10 text-primary ring-1 ring-primary/20"
: "text-muted-foreground hover:bg-muted/70"
)}
>
<ProviderIcon
provider={p.icon}
size={14}
/>
<span>{p.label}</span>
</button>
)
})}
</div>
{/* description */}
<p className="text-[11px] text-muted-foreground">
{info.description}
</p>
{/* credential inputs */}
{(info.needsApiKey || info.needsBaseUrl) && (
<div className="space-y-2">
{info.needsApiKey && (
<div className="space-y-1">
<Label className="text-[11px]">
API Key
</Label>
<div className="relative">
<Input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) =>
setApiKey(e.target.value)
}
placeholder={
hasStoredKey
? "Key saved (enter new to replace)"
: activeType === "openrouter"
? "sk-or-..."
: activeType === "anthropic-key"
? "sk-ant-..."
: "API key"
}
className="h-8 pr-10 text-xs"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-1">
<button
type="button"
onClick={() =>
setShowKey((v) => !v)
}
className="rounded p-1 text-muted-foreground hover:text-foreground"
aria-label={
showKey ? "Hide key" : "Show key"
}
>
{showKey ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</div>
)}
{info.needsBaseUrl && (
<div className="space-y-1">
<Label className="text-[11px]">
Base URL
</Label>
<Input
type="text"
value={
baseUrl || info.defaultBaseUrl || ""
}
onChange={(e) =>
setBaseUrl(e.target.value)
}
placeholder={
info.defaultBaseUrl ?? "https://..."
}
className="h-8 text-xs"
/>
</div>
)}
</div>
)}
{/* actions */}
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-8"
onClick={handleSave}
disabled={saving}
>
{saving && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
Save Provider
</Button>
{activeType !== "anthropic-oauth" && (
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
onClick={handleClear}
disabled={saving}
>
<X className="mr-1 h-3 w-3" />
Reset to default
</Button>
)}
</div>
</div>
)
}
// ============================================================================
// Two-panel model picker (existing, for OpenRouter admin)
// ============================================================================
function ModelPicker({ function ModelPicker({
groups, groups,
@ -159,7 +503,6 @@ function ModelPicker({
const query = search.toLowerCase() const query = search.toLowerCase()
// sort: providers with logos first, then alphabetical
const sortedGroups = React.useMemo(() => { const sortedGroups = React.useMemo(() => {
return [...groups].sort((a, b) => { return [...groups].sort((a, b) => {
const aHas = hasLogo(a.provider) ? 0 : 1 const aHas = hasLogo(a.provider) ? 0 : 1
@ -169,7 +512,6 @@ function ModelPicker({
}) })
}, [groups]) }, [groups])
// filter models by search + active provider + cost ceiling
const filteredGroups = React.useMemo(() => { const filteredGroups = React.useMemo(() => {
return sortedGroups return sortedGroups
.map((group) => { .map((group) => {
@ -240,7 +582,6 @@ function ModelPicker({
return ( return (
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<div className="space-y-3"> <div className="space-y-3">
{/* search bar */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
@ -251,9 +592,7 @@ function ModelPicker({
/> />
</div> </div>
{/* two-panel layout - no outer border */}
<div className="flex gap-2 h-80"> <div className="flex gap-2 h-80">
{/* provider sidebar */}
<div className="w-12 shrink-0 overflow-y-auto flex flex-col items-center gap-1 py-0.5"> <div className="w-12 shrink-0 overflow-y-auto flex flex-col items-center gap-1 py-0.5">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -315,7 +654,6 @@ function ModelPicker({
))} ))}
</div> </div>
{/* model list */}
<div className="flex-1 overflow-y-auto pr-1"> <div className="flex-1 overflow-y-auto pr-1">
{filteredGroups.length === 0 ? ( {filteredGroups.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs"> <div className="flex items-center justify-center h-full text-muted-foreground text-xs">
@ -380,7 +718,6 @@ function ModelPicker({
</div> </div>
</div> </div>
{/* save bar */}
{isDirty && ( {isDirty && (
<div className="flex items-center justify-between pt-1"> <div className="flex items-center justify-between pt-1">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -407,7 +744,9 @@ function ModelPicker({
) )
} }
// --- usage metrics --- // ============================================================================
// Usage section
// ============================================================================
const chartConfig = { const chartConfig = {
tokens: { tokens: {
@ -527,7 +866,9 @@ function UsageSection({
) )
} }
// --- main tab --- // ============================================================================
// Main tab
// ============================================================================
export function AIModelTab() { export function AIModelTab() {
const [loading, setLoading] = React.useState(true) const [loading, setLoading] = React.useState(true)
@ -631,6 +972,13 @@ export function AIModelTab() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Provider configuration — always visible */}
<ProviderConfigSection
onProviderChanged={loadData}
/>
<Separator />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Active Model</Label> <Label className="text-xs">Active Model</Label>
{activeConfig ? ( {activeConfig ? (

View File

@ -38,6 +38,22 @@ export const userModelPreference = sqliteTable(
} }
) )
// per-user provider configuration
export const userProviderConfig = sqliteTable(
"user_provider_config",
{
userId: text("user_id")
.primaryKey()
.references(() => users.id),
providerType: text("provider_type").notNull(), // anthropic-oauth | anthropic-key | openrouter | ollama | custom
apiKey: text("api_key"), // encrypted, nullable
baseUrl: text("base_url"), // nullable
modelOverrides: text("model_overrides"), // JSON, nullable
isActive: integer("is_active").notNull().default(1),
updatedAt: text("updated_at").notNull(),
}
)
// one row per streamText invocation // one row per streamText invocation
export const agentUsage = sqliteTable("agent_usage", { export const agentUsage = sqliteTable("agent_usage", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@ -65,6 +81,8 @@ export const agentUsage = sqliteTable("agent_usage", {
export type AgentConfig = typeof agentConfig.$inferSelect export type AgentConfig = typeof agentConfig.$inferSelect
export type NewAgentConfig = typeof agentConfig.$inferInsert export type NewAgentConfig = typeof agentConfig.$inferInsert
export type UserProviderConfig = typeof userProviderConfig.$inferSelect
export type NewUserProviderConfig = typeof userProviderConfig.$inferInsert
export type AgentUsage = typeof agentUsage.$inferSelect export type AgentUsage = typeof agentUsage.$inferSelect
export type NewAgentUsage = typeof agentUsage.$inferInsert export type NewAgentUsage = typeof agentUsage.$inferInsert
export type UserModelPreference = export type UserModelPreference =

376
src/hooks/use-agent.ts Normal file
View File

@ -0,0 +1,376 @@
"use client"
import { useState, useRef, useCallback } from "react"
import type { AgentMessage, SSEEvent } from "@/lib/agent/message-types"
import { dispatchToolActions } from "@/lib/agent/chat-adapter"
import { getAgentModelId } from "@/components/agent/model-dropdown"
export interface UseAgentOptions {
readonly agentServerUrl?: string
readonly sessionId?: string
readonly currentPage?: string
readonly timezone?: string
readonly onFinish?: (messages: ReadonlyArray<AgentMessage>) => void | Promise<void>
}
export interface UseAgentReturn {
readonly messages: ReadonlyArray<AgentMessage>
setMessages: (
msgs: AgentMessage[] | ((prev: AgentMessage[]) => AgentMessage[])
) => void
sendMessage: (params: { text: string }) => void
stop: () => void
regenerate: () => void
readonly status: "ready" | "streaming" | "error"
readonly error: string | null
}
/**
* Core SSE consumer hook for the agent server
* Handles streaming, parsing, and message accumulation
*/
export function useAgent(options: UseAgentOptions = {}): UseAgentReturn {
const {
agentServerUrl = "http://localhost:3001",
sessionId = crypto.randomUUID(),
currentPage = "/dashboard",
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
onFinish,
} = options
const [messages, setMessages] = useState<AgentMessage[]>([])
const [status, setStatus] = useState<"ready" | "streaming" | "error">("ready")
const [error, setError] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const dispatchedRef = useRef(new Set<string>())
const sendMessage = useCallback(
async (params: { text: string }) => {
if (status === "streaming") return
if (!params.text.trim()) return
// add user message
const userMessage: AgentMessage = {
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: params.text }],
createdAt: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setStatus("streaming")
setError(null)
// create assistant message stub
const assistantId = crypto.randomUUID()
const assistantMessage: AgentMessage = {
id: assistantId,
role: "assistant",
parts: [],
createdAt: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
// prepare request
const controller = new AbortController()
abortControllerRef.current = controller
try {
// get auth token from server action
const { getAgentToken } = await import("@/app/actions/agent-auth")
const tokenResult = await getAgentToken()
if ("error" in tokenResult) {
throw new Error(tokenResult.error)
}
const allMessages = [...messages, userMessage]
// POST to agent server
const response = await fetch(`${agentServerUrl}/agent/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenResult.token}`,
"x-session-id": sessionId,
"x-current-page": currentPage,
"x-timezone": timezone,
"x-model": getAgentModelId(),
},
body: JSON.stringify({
messages: allMessages.map((m) => ({
role: m.role,
content: m.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string }).text)
.join(""),
})),
}),
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`Agent server error: ${response.status}`)
}
if (!response.body) {
throw new Error("No response body")
}
// parse SSE stream
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
// accumulate parts for current assistant message
const parts: Array<
AgentMessage["parts"][number]
> = []
// track active tool calls for state updates
const toolCallMap = new Map<
string,
{ name: string; args: unknown; state: "partial-call" | "call" | "result" }
>()
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// split on SSE boundaries
const lines = buffer.split("\n\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6).trim()
if (data === "[DONE]") {
// stream complete
setStatus("ready")
const finalMessages = [
...messages,
userMessage,
{ ...assistantMessage, parts },
]
setMessages(finalMessages)
if (onFinish) {
await onFinish(finalMessages)
}
return
}
try {
const event = JSON.parse(data) as SSEEvent
switch (event.type) {
case "text": {
// streaming text delta — append to last text part or create new
const lastPart = parts[parts.length - 1]
if (lastPart?.type === "text") {
parts[parts.length - 1] = {
type: "text",
text: lastPart.text + event.content,
}
} else {
parts.push({ type: "text", text: event.content })
}
break
}
case "tool_use": {
// create tool-call part
toolCallMap.set(event.toolCallId, {
name: event.name,
args: event.input,
state: "call",
})
parts.push({
type: "tool-call",
toolName: event.name,
toolCallId: event.toolCallId,
args: event.input,
state: "call",
})
break
}
case "tool_result": {
// update tool-call state to "result"
const toolCall = toolCallMap.get(event.toolCallId)
if (toolCall) {
toolCall.state = "result"
// find and update the tool-call part
const idx = parts.findIndex(
(p) =>
p.type === "tool-call" &&
p.toolCallId === event.toolCallId
)
if (idx !== -1) {
const existingPart = parts[idx]
if (existingPart.type === "tool-call") {
parts[idx] = { ...existingPart, state: "result" }
}
}
}
// add tool-result part
parts.push({
type: "tool-result",
toolCallId: event.toolCallId,
result: event.output,
isError: false,
})
// check for action dispatch
const output = event.output as Record<string, unknown> | null
if (output?.action && !dispatchedRef.current.has(event.toolCallId)) {
dispatchedRef.current.add(event.toolCallId)
// dispatch as if it were a tool part with output
dispatchToolActions(
[
{
type: "tool-result",
toolCallId: event.toolCallId,
state: "output-available",
output: event.output,
},
],
dispatchedRef.current
)
}
break
}
case "tool_progress": {
// optional progress event — could be used for loading states
// for now, just log it (or could update tool-call state)
console.log(
`[agent] tool progress: ${event.toolName} (${event.elapsedSeconds}s)`
)
break
}
case "result": {
// Result text is the full response — already streamed
// via text deltas, so don't append it again.
if (event.usage) {
console.log("[agent] usage:", {
input: event.usage.inputTokens,
output: event.usage.outputTokens,
cost: `$${event.usage.totalCostUsd.toFixed(4)}`,
})
}
break
}
case "error": {
setError(event.error)
setStatus("error")
return
}
}
// update UI with accumulated parts
setMessages((prev) => {
const updated = [...prev]
const lastIdx = updated.length - 1
if (
lastIdx >= 0 &&
updated[lastIdx].role === "assistant"
) {
updated[lastIdx] = {
...updated[lastIdx],
parts: [...parts],
}
}
return updated
})
} catch (parseErr) {
console.error("Failed to parse SSE event:", parseErr)
}
}
}
// if we exit the loop without [DONE], treat as complete
setStatus("ready")
const finalMessages = [
...messages,
userMessage,
{ ...assistantMessage, parts },
]
setMessages(finalMessages)
if (onFinish) {
await onFinish(finalMessages)
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
setStatus("ready")
} else {
const errMsg =
err instanceof Error ? err.message : "Unknown error"
setError(errMsg)
setStatus("error")
}
} finally {
abortControllerRef.current = null
}
},
[
messages,
status,
agentServerUrl,
sessionId,
currentPage,
timezone,
onFinish,
]
)
const stop = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
}, [])
const regenerate = useCallback(() => {
// remove last assistant message and resend
setMessages((prev) => {
const filtered = prev.filter(
(m, i) => i !== prev.length - 1 || m.role !== "assistant"
)
const lastUser = [...filtered].reverse().find((m) => m.role === "user")
if (lastUser) {
// extract text from last user message
const text = lastUser.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string }).text)
.join("")
// re-send (but keep the filtered messages first)
setTimeout(() => sendMessage({ text }), 0)
return filtered
}
return filtered
})
}, [sendMessage])
return {
messages,
setMessages,
sendMessage,
stop,
regenerate,
status,
error,
}
}

View File

@ -2,12 +2,6 @@
import { useEffect, useMemo, useRef } from "react" import { useEffect, useMemo, useRef } from "react"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { useChat } from "@ai-sdk/react"
import {
DefaultChatTransport,
type ChatTransport,
type UIMessage,
} from "ai"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
initializeActionHandlers, initializeActionHandlers,
@ -15,40 +9,16 @@ import {
dispatchToolActions, dispatchToolActions,
ALL_HANDLER_TYPES, ALL_HANDLER_TYPES,
} from "@/lib/agent/chat-adapter" } from "@/lib/agent/chat-adapter"
import { useAgent } from "@/hooks/use-agent"
import type { AgentMessage } from "@/lib/agent/message-types"
interface UseCompassChatOptions { interface UseCompassChatOptions {
readonly conversationId?: string | null readonly conversationId?: string | null
readonly onFinish?: (params: { readonly onFinish?: (params: {
messages: ReadonlyArray<UIMessage> messages: ReadonlyArray<AgentMessage>
}) => void | Promise<void> }) => void | Promise<void>
readonly openPanel?: () => void readonly openPanel?: () => void
readonly bridgeTransport?: readonly bridgeTransport?: unknown | null // placeholder for future bridge integration
| ChatTransport<UIMessage>
| null
}
// useChat captures transport at init -- this wrapper
// delegates at send-time so bridge/default swaps work
class DynamicTransport
implements ChatTransport<UIMessage>
{
private resolve: () => ChatTransport<UIMessage>
constructor(
resolve: () => ChatTransport<UIMessage>
) {
this.resolve = resolve
}
sendMessages: ChatTransport<UIMessage>["sendMessages"] =
(options) => {
return this.resolve().sendMessages(options)
}
reconnectToStream: ChatTransport<UIMessage>["reconnectToStream"] =
async (options) => {
return this.resolve().reconnectToStream(options)
}
} }
export function useCompassChat(options?: UseCompassChatOptions) { export function useCompassChat(options?: UseCompassChatOptions) {
@ -62,68 +32,46 @@ export function useCompassChat(options?: UseCompassChatOptions) {
const dispatchedRef = useRef(new Set<string>()) const dispatchedRef = useRef(new Set<string>())
const bridgeRef = useRef(options?.bridgeTransport) // use the new agent hook
bridgeRef.current = options?.bridgeTransport const agent = useAgent({
agentServerUrl: "http://localhost:3001",
const defaultTransport = useMemo( sessionId: options?.conversationId ?? undefined,
() => currentPage: pathname,
new DefaultChatTransport({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
api: "/api/agent", onFinish: options?.onFinish
headers: { ? (messages) => options.onFinish?.({ messages })
"x-current-page": pathname, : undefined,
"x-timezone":
Intl.DateTimeFormat().resolvedOptions()
.timeZone,
"x-conversation-id":
options?.conversationId ?? "",
},
}),
[pathname, options?.conversationId]
)
const defaultRef = useRef(defaultTransport)
defaultRef.current = defaultTransport
// stable transport -- delegates at send-time
const transport = useMemo(
() =>
new DynamicTransport(() => {
if (bridgeRef.current) {
console.log(
"[chat] routing → bridge transport"
)
return bridgeRef.current
}
console.log(
"[chat] routing → default transport"
)
return defaultRef.current
}),
[]
)
const chatState = useChat({
transport,
onFinish: options?.onFinish,
onError: (err) => {
toast.error(err.message)
},
}) })
const isGenerating = const isGenerating =
chatState.status === "streaming" || agent.status === "streaming"
chatState.status === "submitted"
// dispatch tool-based client actions on new messages // dispatch tool-based client actions on new messages
useEffect(() => { useEffect(() => {
const last = chatState.messages.at(-1) const last = agent.messages.at(-1)
if (last?.role !== "assistant") return if (last?.role !== "assistant") return
// convert AgentPart[] to the format expected by dispatchToolActions
const toolParts = last.parts
.filter((p) => p.type === "tool-result")
.map((p) => {
const toolResult = p as Extract<
(typeof last.parts)[number],
{ type: "tool-result" }
>
return {
type: "tool-result",
toolCallId: toolResult.toolCallId,
state: "output-available",
output: toolResult.result,
}
})
dispatchToolActions( dispatchToolActions(
last.parts as ReadonlyArray<Record<string, unknown>>, toolParts as ReadonlyArray<Record<string, unknown>>,
dispatchedRef.current dispatchedRef.current
) )
}, [chatState.messages]) }, [agent.messages])
// initialize action handlers // initialize action handlers
useEffect(() => { useEffect(() => {
@ -159,13 +107,13 @@ export function useCompassChat(options?: UseCompassChatOptions) {
}, []) }, [])
return { return {
messages: chatState.messages, messages: agent.messages,
setMessages: chatState.setMessages, setMessages: agent.setMessages,
sendMessage: chatState.sendMessage, sendMessage: agent.sendMessage,
regenerate: chatState.regenerate, regenerate: agent.regenerate,
stop: chatState.stop, stop: agent.stop,
status: chatState.status, status: agent.status,
error: chatState.error, error: agent.error,
isGenerating, isGenerating,
pathname, pathname,
} }

View File

@ -1,77 +1,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest" import { describe, it, expect, vi, beforeEach } from "vitest"
// the ws-transport module is "use client" and relies on describe("ws-transport", () => {
// browser globals (WebSocket, localStorage, window).
// we test what we can: the detectBridge timeout logic
// and the constructor / getApiKey behavior via mocks.
describe("WebSocketChatTransport", () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
it("can be imported without throwing", async () => { it("exports BRIDGE_PORT and detectBridge", async () => {
// mock WebSocket globally so the module loads
vi.stubGlobal( vi.stubGlobal(
"WebSocket", "WebSocket",
class { class {
static OPEN = 1
readyState = 0
close = vi.fn() close = vi.fn()
send = vi.fn()
onopen: (() => void) | null = null onopen: (() => void) | null = null
onmessage: ((e: unknown) => void) | null = null
onerror: (() => void) | null = null onerror: (() => void) | null = null
onclose: (() => void) | null = null
addEventListener = vi.fn()
removeEventListener = vi.fn()
}, },
) )
const mod = await import("../ws-transport") const mod = await import("../ws-transport")
expect(mod.WebSocketChatTransport).toBeDefined()
expect(mod.BRIDGE_PORT).toBe(18789) expect(mod.BRIDGE_PORT).toBe(18789)
}) expect(typeof mod.detectBridge).toBe("function")
it("getApiKey returns null when window is undefined", { timeout: 15000 }, async () => {
// simulate server-side: no window
const originalWindow = globalThis.window
// @ts-expect-error intentionally removing window
delete globalThis.window
vi.stubGlobal(
"WebSocket",
class {
static OPEN = 1
readyState = 0
close = vi.fn()
send = vi.fn()
onopen: (() => void) | null = null
onmessage: ((e: unknown) => void) | null = null
onerror: (() => void) | null = null
onclose: (() => void) | null = null
addEventListener = vi.fn()
removeEventListener = vi.fn()
},
)
// re-import fresh
vi.resetModules()
const { WebSocketChatTransport } = await import(
"../ws-transport"
)
const transport = new WebSocketChatTransport()
// ensureConnected should reject because getApiKey
// returns null (or times out trying to connect)
await expect(
(transport as unknown as {
ensureConnected: () => Promise<void>
}).ensureConnected(),
).rejects.toThrow()
// restore window
globalThis.window = originalWindow
}) })
}) })
@ -89,7 +35,6 @@ describe("detectBridge", () => {
onerror: (() => void) | null = null onerror: (() => void) | null = null
onopen: (() => void) | null = null onopen: (() => void) | null = null
constructor() { constructor() {
// fire error on next tick
setTimeout(() => { setTimeout(() => {
if (this.onerror) this.onerror() if (this.onerror) this.onerror()
}, 0) }, 0)
@ -141,7 +86,6 @@ describe("detectBridge", () => {
close = vi.fn() close = vi.fn()
onerror: (() => void) | null = null onerror: (() => void) | null = null
onopen: (() => void) | null = null onopen: (() => void) | null = null
// never fires onopen or onerror
}, },
) )
@ -149,7 +93,6 @@ describe("detectBridge", () => {
const { detectBridge } = await import("../ws-transport") const { detectBridge } = await import("../ws-transport")
const promise = detectBridge("ws://localhost:18789") const promise = detectBridge("ws://localhost:18789")
// advance past the 3000ms CONNECT_TIMEOUT
await vi.advanceTimersByTimeAsync(3500) await vi.advanceTimersByTimeAsync(3500)
const result = await promise const result = await promise
expect(result).toBe(false) expect(result).toBe(false)

View File

@ -0,0 +1,64 @@
"use client"
import type { AgentMessage } from "@/lib/agent/message-types"
/**
* Transport abstraction for agent communication
* Supports both SSE (web/mobile) and WebSocket (desktop bridge)
*/
export interface AgentTransport {
sendMessages(options: {
messages: ReadonlyArray<AgentMessage>
headers?: Record<string, string>
signal?: AbortSignal
}): Promise<ReadableStream<Uint8Array>>
}
/**
* SSE transport for agent server
*/
export class AgentSSETransport implements AgentTransport {
constructor(
private readonly config: {
url: string
getAuthToken: () => Promise<string>
}
) {}
async sendMessages(options: {
messages: ReadonlyArray<AgentMessage>
headers?: Record<string, string>
signal?: AbortSignal
}): Promise<ReadableStream<Uint8Array>> {
const token = await this.config.getAuthToken()
const response = await fetch(`${this.config.url}/agent/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...options.headers,
},
body: JSON.stringify({
messages: options.messages.map((m) => ({
role: m.role,
content: m.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string }).text)
.join(""),
})),
}),
signal: options.signal,
})
if (!response.ok) {
throw new Error(`Agent server error: ${response.status}`)
}
if (!response.body) {
throw new Error("No response body")
}
return response.body
}
}

94
src/lib/agent/api-auth.ts Normal file
View File

@ -0,0 +1,94 @@
import { SignJWT, jwtVerify } from "jose"
export type AuthResult =
| {
readonly valid: true
readonly userId: string
readonly orgId: string
readonly role: string
readonly isDemoUser: boolean
}
| {
readonly valid: false
}
/**
* Validate JWT from the Authorization header.
* Uses AGENT_AUTH_SECRET env var (shared secret with agent server).
* Returns auth context if valid, otherwise { valid: false }.
*/
export async function validateAgentAuth(
req: Request,
env: Record<string, string>,
): Promise<AuthResult> {
const authHeader = req.headers.get("Authorization")
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return { valid: false }
}
const token = authHeader.replace("Bearer ", "")
const secret = env.AGENT_AUTH_SECRET
if (!secret) {
console.error("AGENT_AUTH_SECRET not configured")
return { valid: false }
}
try {
const secretKey = new TextEncoder().encode(secret)
const { payload } = await jwtVerify(token, secretKey, {
algorithms: ["HS256"],
})
const userId = payload.sub
const orgId = payload.orgId
const role = payload.role
const isDemoUser = payload.isDemoUser
if (
typeof userId !== "string" ||
typeof orgId !== "string" ||
typeof role !== "string" ||
typeof isDemoUser !== "boolean"
) {
console.error("Invalid JWT claims", { userId, orgId, role, isDemoUser })
return { valid: false }
}
return {
valid: true,
userId,
orgId,
role,
isDemoUser,
}
} catch (error) {
console.error("JWT verification failed:", error)
return { valid: false }
}
}
/**
* Generate a JWT for testing/server-to-server auth.
* Used by the agent server when making requests to the Workers API.
*/
export async function generateAgentToken(
secret: string,
userId: string,
orgId: string,
role: string,
isDemoUser: boolean,
): Promise<string> {
const secretKey = new TextEncoder().encode(secret)
const token = await new SignJWT({
sub: userId,
orgId,
role,
isDemoUser,
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("24h")
.sign(secretKey)
return token
}

View File

@ -2,12 +2,14 @@
// --- Shared utilities --- // --- Shared utilities ---
import type { AgentMessage } from "@/lib/agent/message-types"
export function getTextFromParts( export function getTextFromParts(
parts: ReadonlyArray<{ type: string; text?: string }> parts: ReadonlyArray<AgentMessage["parts"][number]>
): string { ): string {
return parts return parts
.filter( .filter(
(p): p is { type: "text"; text: string } => (p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text" p.type === "text"
) )
.map((p) => p.text) .map((p) => p.text)

View File

@ -1,302 +0,0 @@
import { tool } from "ai"
import { z } from "zod/v4"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { drizzle } from "drizzle-orm/d1"
import { eq } from "drizzle-orm"
import { getCurrentUser } from "@/lib/auth"
import { feedbackInterviews } from "@/db/schema"
import {
getGitHubConfig,
fetchCommits,
fetchCommitDiff,
fetchPullRequests,
fetchIssues,
fetchContributors,
fetchMilestones,
fetchRepoStats,
createIssue,
} from "@/lib/github/client"
// -- queryGitHub --
const queryGitHubSchema = z.object({
queryType: z.enum([
"commits",
"commit_diff",
"pull_requests",
"issues",
"contributors",
"milestones",
"repo_stats",
]).describe("Type of GitHub data to query"),
sha: z
.string()
.optional()
.describe("Commit SHA for commit_diff queries"),
state: z
.enum(["open", "closed", "all"])
.optional()
.describe("State filter for PRs, issues, milestones"),
labels: z
.string()
.optional()
.describe("Comma-separated labels to filter issues"),
limit: z
.number()
.optional()
.describe("Max results to return (default 10)"),
})
type QueryGitHubInput = z.infer<typeof queryGitHubSchema>
async function executeQueryGitHub(input: QueryGitHubInput) {
const cfgResult = await getGitHubConfig()
if (!cfgResult.success) return { error: cfgResult.error }
const cfg = cfgResult.config
switch (input.queryType) {
case "commits": {
const res = await fetchCommits(cfg, input.limit ?? 10)
if (!res.success) return { error: res.error }
return { data: res.data, count: res.data.length }
}
case "commit_diff": {
if (!input.sha) return { error: "sha is required for commit_diff" }
const res = await fetchCommitDiff(cfg, input.sha)
if (!res.success) return { error: res.error }
return { data: res.data }
}
case "pull_requests": {
const res = await fetchPullRequests(
cfg,
input.state ?? "open",
input.limit ?? 10,
)
if (!res.success) return { error: res.error }
return { data: res.data, count: res.data.length }
}
case "issues": {
const res = await fetchIssues(
cfg,
input.state ?? "open",
input.labels,
input.limit ?? 10,
)
if (!res.success) return { error: res.error }
return { data: res.data, count: res.data.length }
}
case "contributors": {
const res = await fetchContributors(cfg)
if (!res.success) return { error: res.error }
return { data: res.data, count: res.data.length }
}
case "milestones": {
const res = await fetchMilestones(cfg, input.state ?? "open")
if (!res.success) return { error: res.error }
return { data: res.data, count: res.data.length }
}
case "repo_stats": {
const res = await fetchRepoStats(cfg)
if (!res.success) return { error: res.error }
return { data: res.data }
}
default:
return { error: "Unknown query type" }
}
}
// -- createGitHubIssue --
const createGitHubIssueSchema = z.object({
title: z.string().describe("Issue title"),
body: z.string().describe("Issue body in markdown"),
labels: z
.array(z.string())
.optional()
.describe("Labels to apply"),
assignee: z
.string()
.optional()
.describe("GitHub username to assign"),
milestone: z
.number()
.optional()
.describe("Milestone number to attach to"),
})
type CreateGitHubIssueInput = z.infer<typeof createGitHubIssueSchema>
async function executeCreateGitHubIssue(
input: CreateGitHubIssueInput,
) {
const cfgResult = await getGitHubConfig()
if (!cfgResult.success) return { error: cfgResult.error }
const res = await createIssue(
cfgResult.config,
input.title,
input.body,
input.labels,
input.assignee,
input.milestone,
)
if (!res.success) return { error: res.error }
return {
data: {
issueNumber: res.data.number,
issueUrl: res.data.url,
title: res.data.title,
},
}
}
// -- saveInterviewFeedback --
const interviewResponseSchema = z.object({
question: z.string(),
answer: z.string(),
})
const saveInterviewSchema = z.object({
responses: z
.array(interviewResponseSchema)
.describe("Array of question/answer pairs from the interview"),
summary: z
.string()
.describe("Brief summary of the interview findings"),
painPoints: z
.array(z.string())
.optional()
.describe("Key pain points identified"),
featureRequests: z
.array(z.string())
.optional()
.describe("Feature requests from the user"),
overallSentiment: z
.enum(["positive", "neutral", "negative", "mixed"])
.describe("Overall sentiment of the feedback"),
})
type SaveInterviewInput = z.infer<typeof saveInterviewSchema>
async function executeSaveInterview(input: SaveInterviewInput) {
const user = await getCurrentUser()
if (!user) return { error: "Not authenticated" }
const { env } = await getCloudflareContext()
const db = drizzle(env.DB)
const id = crypto.randomUUID()
const createdAt = new Date().toISOString()
await db.insert(feedbackInterviews).values({
id,
userId: user.id,
userName: user.displayName ?? user.email,
userRole: user.role,
responses: JSON.stringify(input.responses),
summary: input.summary,
painPoints: input.painPoints
? JSON.stringify(input.painPoints)
: null,
featureRequests: input.featureRequests
? JSON.stringify(input.featureRequests)
: null,
overallSentiment: input.overallSentiment,
createdAt,
})
// try to create a github issue with the feedback
let githubIssueUrl: string | null = null
const cfgResult = await getGitHubConfig()
if (cfgResult.success) {
const title = `[UX Feedback] ${input.summary.slice(0, 60)}${input.summary.length > 60 ? "..." : ""}`
const body = formatInterviewBody(user, input, createdAt)
const issueRes = await createIssue(
cfgResult.config,
title,
body,
["user-feedback"],
)
if (issueRes.success) {
githubIssueUrl = issueRes.data.url
await db
.update(feedbackInterviews)
.set({ githubIssueUrl })
.where(eq(feedbackInterviews.id, id))
}
}
return {
data: { interviewId: id, githubIssueUrl },
}
}
function formatInterviewBody(
user: { displayName: string | null; email: string; role: string },
input: SaveInterviewInput,
createdAt: string,
): string {
const qa = input.responses
.map(
(r, i) =>
`### Q${i + 1}: ${r.question}\n${r.answer}`,
)
.join("\n\n")
const sections = [
`## UX Interview Feedback\n`,
`**User:** ${user.displayName ?? user.email}`,
`**Role:** ${user.role}`,
`**Sentiment:** ${input.overallSentiment}`,
`**Date:** ${createdAt}\n`,
`## Summary\n${input.summary}\n`,
`## Responses\n${qa}`,
]
if (input.painPoints?.length) {
sections.push(
`\n## Pain Points\n${input.painPoints.map((p) => `- ${p}`).join("\n")}`,
)
}
if (input.featureRequests?.length) {
sections.push(
`\n## Feature Requests\n${input.featureRequests.map((f) => `- ${f}`).join("\n")}`,
)
}
return sections.join("\n")
}
// -- Export --
export const githubTools = {
queryGitHub: tool({
description:
"Query GitHub repository data: commits, pull requests, " +
"issues, contributors, milestones, or repo stats.",
inputSchema: queryGitHubSchema,
execute: async (input: QueryGitHubInput) =>
executeQueryGitHub(input),
}),
createGitHubIssue: tool({
description:
"Create a new GitHub issue in the Compass repository. " +
"Always confirm with the user before creating.",
inputSchema: createGitHubIssueSchema,
execute: async (input: CreateGitHubIssueInput) =>
executeCreateGitHubIssue(input),
}),
saveInterviewFeedback: tool({
description:
"Save the results of a UX interview. Call this after " +
"completing an interview with the user. Saves to the " +
"database and creates a GitHub issue tagged user-feedback.",
inputSchema: saveInterviewSchema,
execute: async (input: SaveInterviewInput) =>
executeSaveInterview(input),
}),
}

View File

@ -0,0 +1,83 @@
"use client"
/**
* SSE event types from agent server (packages/agent-server/src/stream.ts)
*
* The agent server converts SDK messages into a flat SSE protocol:
* - text: streaming text deltas
* - tool_use: tool invocation with name, ID, and input
* - tool_result: tool execution result
* - tool_progress: optional progress updates during long-running tools
* - result: final result with usage stats
* - error: error message
*/
export type SSEEvent =
| { readonly type: "text"; readonly content: string }
| {
readonly type: "tool_use"
readonly name: string
readonly toolCallId: string
readonly input: unknown
}
| {
readonly type: "tool_result"
readonly toolCallId: string
readonly output: unknown
}
| {
readonly type: "tool_progress"
readonly toolName: string
readonly toolCallId: string
readonly elapsedSeconds: number
}
| {
readonly type: "result"
readonly subtype: "success" | "error"
readonly result: string
readonly usage?: {
readonly inputTokens: number
readonly outputTokens: number
readonly totalCostUsd: number
}
}
| { readonly type: "error"; readonly error: string }
/**
* Message format for the chat UI - designed to be compatible
* with existing ChatMessage component rendering logic
*/
export interface AgentMessage {
readonly id: string
readonly role: "user" | "assistant"
readonly parts: ReadonlyArray<AgentPart>
readonly createdAt: Date
}
/**
* Message parts that map to existing rendering patterns in chat-view.tsx
* Each part type corresponds to a specific rendering style:
* - text: normal message content (MessageResponse)
* - tool-call: tool invocation display (Tool component)
* - tool-result: tool output display (ToolOutput)
* - reasoning: collapsible thinking block (Reasoning component)
*/
export type AgentPart =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "tool-call"
readonly toolName: string
readonly toolCallId: string
readonly args: unknown
readonly state: "partial-call" | "call" | "result"
}
| {
readonly type: "tool-result"
readonly toolCallId: string
readonly result: unknown
readonly isError: boolean
}
| {
readonly type: "reasoning"
readonly text: string
readonly state: "streaming" | "complete"
}

View File

@ -1,83 +0,0 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import {
agentConfig,
userModelPreference,
} from "@/db/schema-ai-config"
export const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
export async function getActiveModelId(
db: ReturnType<typeof getDb>
): Promise<string> {
const config = await db
.select({ modelId: agentConfig.modelId })
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
return config?.modelId ?? DEFAULT_MODEL_ID
}
export function createModelFromId(
apiKey: string,
modelId: string
) {
const openrouter = createOpenRouter({ apiKey })
return openrouter(modelId, {
provider: { allow_fallbacks: false },
})
}
export async function resolveModelForUser(
db: ReturnType<typeof getDb>,
userId: string
): Promise<string> {
const config = await db
.select()
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
if (!config) return DEFAULT_MODEL_ID
const globalModelId = config.modelId
const ceiling = config.maxCostPerMillion
? parseFloat(config.maxCostPerMillion)
: null
const pref = await db
.select()
.from(userModelPreference)
.where(eq(userModelPreference.userId, userId))
.get()
if (!pref) return globalModelId
if (ceiling !== null) {
const outputPerMillion =
parseFloat(pref.completionCost) * 1_000_000
if (outputPerMillion > ceiling) return globalModelId
}
return pref.modelId
}
export async function getAgentModel() {
const { env } = await getCloudflareContext()
const apiKey = (env as unknown as Record<string, string>)
.OPENROUTER_API_KEY
if (!apiKey) {
throw new Error(
"OPENROUTER_API_KEY not configured"
)
}
const db = getDb(env.DB)
const modelId = await getActiveModelId(db)
return createModelFromId(apiKey, modelId)
}

View File

@ -1,868 +0,0 @@
import { compassCatalog } from "@/lib/agent/render/catalog"
import type { PromptSection } from "@/lib/agent/plugins/types"
// --- types ---
type PromptMode = "full" | "minimal" | "none" | "demo"
interface DashboardSummary {
readonly id: string
readonly name: string
readonly description: string
}
interface PromptContext {
readonly userName: string
readonly userRole: string
readonly currentPage?: string
readonly memories?: string
readonly timezone?: string
readonly pluginSections?: ReadonlyArray<PromptSection>
readonly dashboards?: ReadonlyArray<DashboardSummary>
readonly mode?: PromptMode
}
type ToolCategory =
| "data"
| "navigation"
| "ui"
| "memory"
| "github"
| "skills"
| "feedback"
| "schedule"
interface ToolMeta {
readonly name: string
readonly summary: string
readonly category: ToolCategory
readonly adminOnly?: true
}
interface DerivedState {
readonly mode: PromptMode
readonly page: string
readonly isAdmin: boolean
readonly catalogComponents: string
readonly tools: ReadonlyArray<ToolMeta>
}
// --- tool registry ---
const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
{
name: "queryData",
summary:
"Query the database for customers, vendors, projects, " +
"invoices, bills, schedule tasks, or record details. " +
"Pass a queryType and optional search/id/limit.",
category: "data",
},
{
name: "navigateTo",
summary:
"Navigate to a page. Side-effect tool — one call is " +
"enough. Do NOT also call queryData or generateUI. " +
"Valid paths: /dashboard, /dashboard/projects, " +
"/dashboard/projects/{id}, " +
"/dashboard/projects/{id}/schedule, " +
"/dashboard/contacts, " +
"/dashboard/financials, /dashboard/people, " +
"/dashboard/files, /dashboard/boards/{id}. " +
"If the page doesn't exist, " +
"tell the user what's available.",
category: "navigation",
},
{
name: "showNotification",
summary:
"Show a toast notification. Use sparingly — only " +
"for confirmations or important alerts.",
category: "ui",
},
{
name: "generateUI",
summary:
"Render a rich interactive dashboard (tables, charts, " +
"stats, forms). Workflow: queryData first, then " +
"generateUI with dataContext. For follow-ups, call " +
"again — the system sends incremental patches.",
category: "ui",
},
{
name: "queryGitHub",
summary:
"Query GitHub for commits, commit_diff, pull_requests, " +
"issues, contributors, milestones, or repo_stats. " +
"Use DataTable for tabular results, StatCard for " +
"repo overview, BarChart for activity viz.",
category: "github",
},
{
name: "createGitHubIssue",
summary:
"Create a GitHub issue. Fields: title (required), " +
"body (markdown, required), labels (optional), " +
"assignee (optional), milestone (optional number). " +
"Always confirm title/body/labels with the user first.",
category: "github",
},
{
name: "rememberContext",
summary:
"Save a preference, decision, fact, or workflow to " +
"persistent memory. Types: preference, workflow, " +
"fact, decision. Proactively save when user shares " +
"something worth retaining — don't ask permission.",
category: "memory",
},
{
name: "recallMemory",
summary:
"Search saved memories. Use when user asks " +
'"do you remember..." or you need a past preference.',
category: "memory",
},
{
name: "installSkill",
summary:
'Install a skill from GitHub (skills.sh format). ' +
'Source: "owner/repo/skill-name" or "owner/repo". ' +
"Confirm with the user before installing.",
category: "skills",
adminOnly: true,
},
{
name: "listInstalledSkills",
summary:
"List installed skills and their enabled/disabled status.",
category: "skills",
},
{
name: "toggleInstalledSkill",
summary: "Enable or disable a skill by its plugin ID.",
category: "skills",
},
{
name: "uninstallSkill",
summary:
"Permanently remove an installed skill. " +
"Confirm before uninstalling.",
category: "skills",
adminOnly: true,
},
{
name: "saveInterviewFeedback",
summary:
"Save completed UX interview results. Call only " +
"after finishing the interview flow. Saves to DB " +
'and creates a GitHub issue tagged "user-feedback".',
category: "feedback",
},
{
name: "listThemes",
summary:
"List all available visual themes (presets and user " +
"custom themes) with their IDs and descriptions.",
category: "ui",
},
{
name: "setTheme",
summary:
"Switch the active visual theme by ID. Preset IDs: " +
"native-compass, corpo, notebook, doom-64, bubblegum, " +
"developers-choice, anslopics-clood, violet-bloom, soy, " +
"mocha. Also accepts custom theme UUIDs.",
category: "ui",
},
{
name: "generateTheme",
summary:
"Create a custom visual theme from scratch. Provide " +
"name, description, light/dark color maps (32 oklch " +
"entries each), fonts, optional Google Font names, " +
"and radius/spacing tokens.",
category: "ui",
},
{
name: "editTheme",
summary:
"Edit an existing custom theme incrementally. " +
"Provide only the properties to change — everything " +
"else is preserved. Only works on custom themes " +
"(not presets).",
category: "ui",
},
{
name: "saveDashboard",
summary:
"Save the current rendered UI as a named dashboard. " +
"The client captures the spec and data automatically. " +
"Pass dashboardId to update an existing dashboard.",
category: "ui",
},
{
name: "listDashboards",
summary:
"List the user's saved custom dashboards with " +
"their IDs, names, and descriptions.",
category: "ui",
},
{
name: "editDashboard",
summary:
"Load a saved dashboard for editing. Loads the spec " +
"into the render context on /dashboard. Optionally " +
"pass editPrompt to trigger immediate re-generation.",
category: "ui",
},
{
name: "deleteDashboard",
summary:
"Delete a saved dashboard. Always confirm with " +
"the user before deleting.",
category: "ui",
},
{
name: "getProjectSchedule",
summary:
"Get a project's full schedule: tasks, dependencies, " +
"exceptions, and a computed summary (counts, overall %, " +
"critical path). Always call before mutations to resolve " +
"task names to UUIDs.",
category: "schedule",
},
{
name: "createScheduleTask",
summary:
"Create a new task on a project schedule. Provide " +
"projectId, title, startDate (YYYY-MM-DD), workdays, " +
"and phase. Optional: isMilestone, percentComplete, " +
"assignedTo.",
category: "schedule",
},
{
name: "updateScheduleTask",
summary:
"Update a schedule task by ID. Provide only the " +
"fields to change: title, startDate, workdays, phase, " +
"status (PENDING/IN_PROGRESS/COMPLETE/BLOCKED), " +
"isMilestone, percentComplete, assignedTo.",
category: "schedule",
},
{
name: "deleteScheduleTask",
summary:
"Delete a schedule task. Always confirm with the " +
"user before deleting.",
category: "schedule",
},
{
name: "createScheduleDependency",
summary:
"Create a dependency between two tasks. Types: " +
"FS (finish-to-start), SS, FF, SF. Optional lagDays. " +
"Has built-in cycle detection.",
category: "schedule",
},
{
name: "deleteScheduleDependency",
summary:
"Delete a dependency between tasks by its ID.",
category: "schedule",
},
]
// categories included in minimal mode
const MINIMAL_CATEGORIES: ReadonlySet<ToolCategory> = new Set([
"data",
"navigation",
"ui",
"schedule",
])
// categories included in demo mode (read-only subset)
const DEMO_CATEGORIES: ReadonlySet<ToolCategory> = new Set([
"data",
"navigation",
"ui",
"schedule",
])
// --- derived state ---
function extractDescription(
entry: unknown,
): string {
if (
typeof entry === "object" &&
entry !== null &&
"description" in entry &&
typeof (entry as Record<string, unknown>).description ===
"string"
) {
return (entry as Record<string, unknown>)
.description as string
}
return ""
}
function computeDerivedState(ctx: PromptContext): DerivedState {
const mode = ctx.mode ?? "full"
const page = ctx.currentPage ?? "dashboard"
const isAdmin = ctx.userRole === "admin"
const catalogComponents = Object.entries(
compassCatalog.data.components,
)
.map(([name, def]) => `- ${name}: ${extractDescription(def)}`)
.join("\n")
const tools =
mode === "none"
? []
: TOOL_REGISTRY.filter((t) => {
if (t.adminOnly && !isAdmin) return false
if (mode === "minimal") {
return MINIMAL_CATEGORIES.has(t.category)
}
if (mode === "demo") {
return DEMO_CATEGORIES.has(t.category)
}
return true
})
return { mode, page, isAdmin, catalogComponents, tools }
}
// --- section builders ---
function buildIdentity(mode: PromptMode): ReadonlyArray<string> {
const line =
"You are Dr. Slab Diggems, the AI assistant built " +
"into Compass — a construction project management platform."
if (mode === "none") return [line]
if (mode === "demo") {
return [
line +
" You are reliable, direct, and always ready to help. " +
"You're currently showing a demo workspace to a prospective user — " +
"be enthusiastic about Compass features and suggest they sign up " +
"when they try to perform mutations.",
]
}
return [line + " You are reliable, direct, and always ready to help."]
}
function buildUserContext(
ctx: PromptContext,
state: DerivedState,
): ReadonlyArray<string> {
if (state.mode === "none") return []
const tz = ctx.timezone ?? "UTC"
const now = new Date()
const date = now.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: tz,
})
const time = now.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})
return [
"## User Context",
`- Name: ${ctx.userName}`,
`- Role: ${ctx.userRole}`,
`- Current page: ${state.page}`,
`- Current date: ${date}`,
`- Current time: ${time} (${tz})`,
]
}
function buildMemoryContext(
ctx: PromptContext,
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## What You Remember About This User",
ctx.memories ||
"No memories yet. When the user shares preferences, " +
"decisions, or important facts, use rememberContext " +
"to save them.",
]
}
function buildFirstInteraction(
mode: PromptMode,
page: string,
): ReadonlyArray<string> {
if (mode !== "full") return []
const suggestions = [
'"I can pull up your active projects, recent invoices, ' +
'or outstanding vendor bills."',
'"Need to check on a schedule, find a customer, or ' +
'navigate somewhere? Just ask."',
'"I can show you charts, tables, and project summaries ' +
'— or just answer a quick question."',
'"Want to check the project\'s development status? I can ' +
'show you recent commits, PRs, issues, and contributor activity."',
'"I can also conduct a quick UX interview if you\'d like ' +
'to share feedback about Compass."',
'"I can build you a custom dashboard with charts and ' +
'stats — and save it so you can access it anytime."',
]
return [
"## First Interaction",
"When a user first messages you or seems unsure what " +
"to ask, proactively offer what you can do. For example:",
...suggestions.map((s) => `- ${s}`),
"",
"Tailor suggestions to the user's current page. " +
(page.includes("project")
? "They're on a projects page — lead with project-specific help."
: page.includes("financial")
? "They're on financials — lead with invoice and billing capabilities."
: page.includes("contact")
? "They're on contacts — lead with customer and vendor management."
: "If they're on the dashboard, offer a broad overview."),
]
}
function buildDomainKnowledge(
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## Domain",
"You help with construction project management: tracking " +
"projects, schedules, customers, vendors, invoices, and " +
"vendor bills. You understand construction terminology " +
"(phases, change orders, submittals, RFIs, punch lists, etc).",
]
}
function buildToolDocs(
tools: ReadonlyArray<ToolMeta>,
): ReadonlyArray<string> {
if (tools.length === 0) return []
return [
"## Available Tools",
...tools.map(
(t) =>
`- **${t.name}**: ${t.summary}` +
(t.adminOnly ? " *(admin only)*" : ""),
),
]
}
function buildCatalogSection(
mode: PromptMode,
catalogComponents: string,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## generateUI Components",
"Available component types for generateUI:",
catalogComponents,
"",
"For follow-up requests while a dashboard is visible, call " +
"generateUI again — the system sends incremental patches.",
"",
"## Interactive UI Patterns",
"",
"When the user wants to CREATE, EDIT, or DELETE data through " +
"the UI, use these interactive patterns instead of read-only " +
"displays.",
"",
"### Creating records with Form",
"Wrap inputs in a Form component. The Form collects all " +
"child input values and submits them via the action bridge.",
"",
"Example — create a customer:",
"```",
'Form(formId="new-customer", action="customer.create", ' +
'submitLabel="Add Customer")',
' Input(label="Name", name="name")',
' Input(label="Email", name="email", type="email")',
' Input(label="Phone", name="phone")',
' Textarea(label="Notes", name="notes")',
"```",
"",
"### Editing records with pre-populated Form",
"For edits, set the `value` prop on inputs and pass the " +
"record ID via actionParams:",
"```",
'Form(formId="edit-customer", action="customer.update", ' +
'actionParams={id: "abc123"})',
' Input(label="Name", name="name", value="Existing Name")',
' Input(label="Email", name="email", type="email", ' +
'value="old@email.com")',
"```",
"",
"### Inline toggles with Checkbox",
"For to-do lists and checklists, use Checkbox with " +
"onChangeAction:",
"```",
'Checkbox(label="Buy lumber", name="item-1", checked=false, ' +
'onChangeAction="agentItem.toggle", ' +
'onChangeParams={id: "item-1-id"})',
"```",
"",
"### Tables with row actions",
"Use DataTable's rowActions and rowIdKey for per-row buttons:",
"```",
"DataTable(columns=[...], data=[...], rowIdKey=\"id\", " +
'rowActions=[{label: "Delete", action: "customer.delete", ' +
'variant: "danger"}])',
"```",
"",
"### Available mutation actions",
"- customer.create, customer.update, customer.delete",
"- vendor.create, vendor.update, vendor.delete",
"- invoice.create, invoice.update, invoice.delete",
"- vendorBill.create, vendorBill.update, vendorBill.delete",
"- schedule.create, schedule.update, schedule.delete",
"- agentItem.create, agentItem.update, agentItem.delete, " +
"agentItem.toggle",
"",
"### When to use interactive vs read-only",
'- User says "show me" / "list" / "what are" -> read-only ' +
"DataTable, charts",
'- User says "add" / "create" / "new" -> Form with action',
'- User says "edit" / "update" / "change" -> pre-populated Form',
'- User says "delete" / "remove" -> DataTable with delete ' +
"rowAction",
'- User says "to-do" / "checklist" / "task list" -> ' +
"Checkbox with onChangeAction",
]
}
function buildInterviewProtocol(
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## User Experience Interviews",
"When a user explicitly asks to give feedback, share their " +
"experience, or participate in a UX interview, conduct a " +
"conversational interview:",
"",
"1. Ask ONE question at a time. Wait for the answer.",
"2. Cover these areas (adapt to the user's role):",
" - How they use Compass day-to-day",
" - What works well for them",
" - Pain points or frustrations",
" - Features they wish existed",
" - How Compass compares to tools they've used before",
" - Bottlenecks in their workflow",
"3. Follow up on interesting answers with deeper questions.",
"4. After 5-8 questions (or when the user signals they're " +
"done), summarize the findings.",
"5. Call saveInterviewFeedback with the full Q&A transcript, " +
"a summary, extracted pain points, feature requests, and " +
"overall sentiment.",
"6. Thank the user for their time.",
"",
"Do NOT start an interview unless the user explicitly asks. " +
"Never pressure users into giving feedback.",
]
}
function buildGitHubGuidance(
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## GitHub API Usage",
"Be respectful of GitHub API rate limits. Avoid making " +
"excessive queries in a single conversation. Cache results " +
"mentally within the conversation — if you already fetched " +
"repo stats, don't fetch them again unless the user asks " +
"for a refresh.",
"",
"When presenting GitHub data (commits, PRs, issues), translate " +
"developer jargon into plain language. Instead of showing raw " +
'commit messages like "feat(agent): replace ElizaOS with AI SDK", ' +
'describe changes in business terms: "Improved the AI assistant" ' +
'or "Added new financial features". Your audience is construction ' +
"professionals, not developers.",
]
}
function buildThemingRules(
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## Visual Theming",
"Users can customize the app's visual theme. You have three " +
"theming tools:",
"",
"**Preset themes** (use setTheme with these IDs):",
"- native-compass: Default teal construction palette",
"- corpo: Clean blue corporate look",
"- notebook: Warm handwritten aesthetic",
"- doom-64: Gritty industrial with sharp edges",
"- bubblegum: Playful pink and pastels",
"- developers-choice: Retro pixel-font terminal",
"- anslopics-clood: Warm amber-orange with clean lines",
"- violet-bloom: Deep violet with elegant rounded corners",
"- soy: Rosy pink and magenta romantic tones",
"- mocha: Coffee-brown earthy palette with offset shadows",
"",
"**When to use which tool:**",
'- "change to corpo" / "switch theme to X" -> setTheme',
'- "what themes are available?" -> listThemes',
'- "make me a sunset theme" / "create a dark red theme" -> ' +
"generateTheme",
'- "make the primary darker" / "change the font to Inter" ' +
'/ "tweak the accent color" -> editTheme ' +
"(when a custom theme is active)",
"",
"**generateTheme rules:**",
"- All 32 color keys required for both light AND dark maps: " +
"background, foreground, card, card-foreground, popover, " +
"popover-foreground, primary, primary-foreground, secondary, " +
"secondary-foreground, muted, muted-foreground, accent, " +
"accent-foreground, destructive, destructive-foreground, " +
"border, input, ring, chart-1 through chart-5, sidebar, " +
"sidebar-foreground, sidebar-primary, " +
"sidebar-primary-foreground, sidebar-accent, " +
"sidebar-accent-foreground, sidebar-border, sidebar-ring",
"- All colors in oklch() format: oklch(L C H) where " +
"L=0-1, C=0-0.4, H=0-360",
"- Light backgrounds: L >= 0.90; Dark backgrounds: L <= 0.25",
"- Ensure ~0.5+ lightness difference between bg and fg " +
"(WCAG AA approximation)",
"- destructive hue in red range (H: 20-50)",
"- 5 chart colors must be visually distinct",
"- Google Font names are case-sensitive",
"- radius: 0-2rem, spacing: 0.2-0.4rem",
"",
"**editTheme rules:**",
"- Only works on custom themes (not presets)",
"- Only provide the fields being changed",
"- For color maps, only include the specific keys being modified",
"- All color values must still be oklch() format",
"- Fonts: only include the font keys being changed " +
"(sans, serif, or mono)",
"- The theme is deep-merged: existing values are preserved " +
"unless explicitly overridden",
"",
"**Color mode vs theme:** Toggling light/dark changes which " +
"palette variant is displayed. Changing theme changes the " +
"entire palette. These are independent.",
]
}
function buildDashboardRules(
ctx: PromptContext,
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
const lines = [
"## Custom Dashboards",
"Users can save generated UIs as persistent dashboards " +
"that appear in the sidebar and can be revisited anytime.",
"",
"**Workflow:**",
"1. User asks for a dashboard (e.g. \"build me a " +
"project overview\")",
"2. Use queryData to fetch data, then generateUI to " +
"build the UI",
"3. Once the user is happy, use saveDashboard to persist it",
"4. The dashboard appears in the sidebar at " +
"/dashboard/boards/{id}",
"",
"**Editing:**",
"- Use editDashboard to load a saved dashboard for editing",
"- After loading, use generateUI to make changes " +
"(the system sends patches against the previous spec)",
"- Use saveDashboard with the dashboardId to save updates",
"",
"**Limits:**",
"- Maximum 5 dashboards per user",
"- If the user hits the limit, suggest deleting one first",
"",
"**When to offer dashboard saving:**",
"- After generating a useful UI the user seems happy with",
'- When the user says "save this" or "keep this"',
"- Don't push it — offer once after a good generation",
]
if (ctx.dashboards?.length) {
lines.push(
"",
"**User's saved dashboards:**",
...ctx.dashboards.map(
(d) => `- ${d.name} (id: ${d.id})` +
(d.description ? `: ${d.description}` : ""),
),
)
}
return lines
}
function buildScheduleGuidance(
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## Schedule Management",
"You can read and modify project schedules directly.",
"",
"**Resolving the projectId:**",
"- If the user is on a project page (URL contains " +
"/dashboard/projects/{id}), extract the projectId " +
"from the currentPage URL.",
"- Otherwise, ask which project or use queryData " +
'(queryType: "projects") to search by name.',
"",
"**Workflow — always read before writing:**",
"1. Call getProjectSchedule to load all tasks and " +
"dependencies.",
"2. Match the user's task name to a task UUID in the " +
"returned list.",
"3. Then call createScheduleTask, updateScheduleTask, " +
"deleteScheduleTask, createScheduleDependency, or " +
"deleteScheduleDependency as needed.",
"",
"**Construction phases:** preconstruction, sitework, " +
"foundation, framing, roofing, electrical, plumbing, " +
"hvac, insulation, drywall, finish, landscaping, closeout.",
"",
"**Task statuses:** PENDING, IN_PROGRESS, COMPLETE, BLOCKED.",
"",
"**Dependency types:**",
"- FS (finish-to-start): successor starts after " +
"predecessor finishes. Most common.",
"- SS (start-to-start): both start together.",
"- FF (finish-to-finish): both finish together.",
"- SF (start-to-finish): predecessor start triggers " +
"successor finish.",
"",
"**When to use getProjectSchedule vs queryData:**",
"- getProjectSchedule: full schedule with dependencies, " +
"critical path, exceptions — use for schedule questions " +
"and before any mutations.",
'- queryData (queryType: "schedule_tasks"): flat search ' +
"across ALL projects — use for cross-project task lookups.",
"",
"**Common patterns:**",
'- "mark X complete" → getProjectSchedule, find task ID, ' +
"updateScheduleTask with status: COMPLETE and " +
"percentComplete: 100.",
'- "what\'s on the critical path?" → getProjectSchedule, ' +
"read summary.criticalPath.",
'- "link X to Y" → getProjectSchedule, find both IDs, ' +
"createScheduleDependency with type FS.",
]
}
function buildGuidelines(
mode: PromptMode,
): ReadonlyArray<string> {
if (mode === "none") return []
const core = [
"## Guidelines",
"- Be concise and helpful. Construction managers are busy.",
"- ACT FIRST, don't ask. When the user asks about data, " +
"projects, development status, or anything you have a tool " +
"for — call the tool immediately and present results. Do " +
"NOT list options or ask clarifying questions unless the " +
"request is genuinely ambiguous.",
"- If you don't know something, say so rather than guessing.",
"- Never fabricate data. Only present what queryData returns.",
]
if (mode === "minimal") return core
if (mode === "demo") {
return [
...core,
"- Demo mode: you can show data and navigate, but when the " +
"user tries to create, edit, or delete records, gently " +
'suggest they sign up. For example: "To create your own ' +
'projects and data, sign up for a free account!"',
"- Be enthusiastic about Compass features. Show off what the " +
"platform can do.",
"- The demo data includes 3 sample projects, customers, " +
"vendors, invoices, and team channels. Use these to " +
"demonstrate capabilities.",
]
}
return [
...core,
"- Tool workflow: data requests -> queryData immediately. " +
"Navigation -> navigateTo, brief confirmation. " +
"Dashboards -> queryData first, then generateUI. " +
"Memories -> save proactively with rememberContext.",
'- "How\'s development going?" means fetch repo_stats and ' +
'recent commits right now, not "Would you like to see ' +
'commits or PRs?"',
"- When asked about data, use queryData to fetch real " +
"information.",
"- For navigation requests, use navigateTo immediately.",
"- After navigating, be brief but warm. A short, friendly " +
"confirmation is all that's needed — don't describe the " +
"page layout.",
"- For data display, prefer generateUI over plain text tables.",
"- Use metric and imperial units as appropriate for construction.",
"- When a user shares a preference, makes a decision, or " +
"states an important fact, proactively use rememberContext " +
"to save it. Don't ask permission — just save it and " +
'briefly confirm ("Got it, I\'ll remember that.").',
]
}
function buildPluginSections(
sections: ReadonlyArray<PromptSection> | undefined,
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
if (!sections?.length) return []
return [
"## Installed Skills",
"",
...sections.map((s) => `### ${s.heading}\n${s.content}`),
]
}
// --- assembler ---
export function buildSystemPrompt(ctx: PromptContext): string {
const state = computeDerivedState(ctx)
const sections: ReadonlyArray<ReadonlyArray<string>> = [
buildIdentity(state.mode),
buildUserContext(ctx, state),
buildMemoryContext(ctx, state.mode),
buildFirstInteraction(state.mode, state.page),
buildDomainKnowledge(state.mode),
buildToolDocs(state.tools),
buildCatalogSection(state.mode, state.catalogComponents),
buildInterviewProtocol(state.mode),
buildGitHubGuidance(state.mode),
buildThemingRules(state.mode),
buildDashboardRules(ctx, state.mode),
buildScheduleGuidance(state.mode),
buildGuidelines(state.mode),
buildPluginSections(ctx.pluginSections, state.mode),
]
return sections
.filter((s) => s.length > 0)
.map((s) => s.join("\n"))
.join("\n\n")
}

File diff suppressed because it is too large Load Diff

View File

@ -1,58 +0,0 @@
import { eq } from "drizzle-orm"
import type { LanguageModelUsage } from "ai"
import type { getDb } from "@/db"
import { agentConfig, agentUsage } from "@/db/schema-ai-config"
interface StreamResult {
readonly totalUsage: PromiseLike<LanguageModelUsage>
}
export async function saveStreamUsage(
db: ReturnType<typeof getDb>,
conversationId: string,
userId: string,
modelId: string,
result: StreamResult
): Promise<void> {
try {
const usage = await result.totalUsage
const promptTokens = usage.inputTokens ?? 0
const completionTokens = usage.outputTokens ?? 0
const totalTokens = usage.totalTokens ?? 0
const config = await db
.select({
promptCost: agentConfig.promptCost,
completionCost: agentConfig.completionCost,
})
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
const promptRate = config
? parseFloat(config.promptCost)
: 0
const completionRate = config
? parseFloat(config.completionCost)
: 0
const estimatedCost =
promptTokens * promptRate +
completionTokens * completionRate
await db.insert(agentUsage).values({
id: crypto.randomUUID(),
conversationId,
userId,
modelId,
promptTokens,
completionTokens,
totalTokens,
estimatedCost: estimatedCost.toFixed(8),
createdAt: new Date().toISOString(),
})
} catch {
// usage tracking must never break the chat
}
}

View File

@ -1,275 +1,13 @@
"use client" "use client"
import type {
ChatTransport,
UIMessage,
UIMessageChunk,
} from "ai"
// bridge protocol message types
type BridgeServerMessage =
| {
readonly type: "auth_ok"
readonly user: {
readonly id: string
readonly name: string
readonly role: string
}
}
| { readonly type: "auth_error"; readonly message: string }
| {
readonly type: "chat.ack"
readonly id: string
readonly runId: string
}
| {
readonly type: "chunk"
readonly runId: string
readonly chunk: UIMessageChunk
}
| { readonly type: "chat.done"; readonly runId: string }
| {
readonly type: "chat.error"
readonly runId: string
readonly error: string
}
| { readonly type: "pong" }
const BRIDGE_PORT = 18789 const BRIDGE_PORT = 18789
const DEFAULT_URL = `ws://localhost:${BRIDGE_PORT}` const DEFAULT_URL = `ws://localhost:${BRIDGE_PORT}`
const AUTH_TIMEOUT = 5000
const CONNECT_TIMEOUT = 3000 const CONNECT_TIMEOUT = 3000
function isBridgeServerMessage( /**
raw: unknown, * Detect if the bridge daemon is running by attempting
): raw is BridgeServerMessage { * a WebSocket connection and checking if it opens.
return ( */
typeof raw === "object" &&
raw !== null &&
"type" in raw
)
}
export class WebSocketChatTransport
implements ChatTransport<UIMessage>
{
private ws: WebSocket | null = null
private authenticated = false
private readonly url: string
private connectPromise: Promise<void> | null = null
constructor(url = DEFAULT_URL) {
this.url = url
}
private async ensureConnected(): Promise<void> {
if (
this.ws?.readyState === WebSocket.OPEN &&
this.authenticated
) {
return
}
if (this.connectPromise) return this.connectPromise
this.connectPromise = new Promise<void>(
(resolve, reject) => {
const ws = new WebSocket(this.url)
let authResolved = false
const timeout = setTimeout(() => {
if (!authResolved) {
authResolved = true
ws.close()
this.connectPromise = null
reject(new Error("bridge auth timeout"))
}
}, AUTH_TIMEOUT)
// daemon auto-authenticates on connect --
// just wait for auth_ok message
ws.onmessage = (event) => {
if (authResolved) return
const raw: unknown = JSON.parse(
String(event.data),
)
if (!isBridgeServerMessage(raw)) return
const msg = raw
if (msg.type === "auth_ok") {
authResolved = true
clearTimeout(timeout)
this.ws = ws
this.authenticated = true
this.connectPromise = null
resolve()
} else if (msg.type === "auth_error") {
authResolved = true
clearTimeout(timeout)
ws.close()
this.connectPromise = null
reject(new Error(msg.message))
}
}
ws.onerror = () => {
if (!authResolved) {
authResolved = true
clearTimeout(timeout)
this.connectPromise = null
reject(
new Error("bridge connection failed")
)
}
}
ws.onclose = () => {
this.ws = null
this.authenticated = false
if (!authResolved) {
authResolved = true
clearTimeout(timeout)
this.connectPromise = null
reject(
new Error("bridge connection closed")
)
}
}
}
)
return this.connectPromise
}
sendMessages: ChatTransport<UIMessage>["sendMessages"] =
async (options) => {
await this.ensureConnected()
const ws = this.ws
if (!ws) throw new Error("bridge not connected")
const messageId = crypto.randomUUID()
// read bridge model preference from localStorage
const bridgeModel =
typeof window !== "undefined"
? localStorage.getItem("compass-bridge-model")
: null
console.log(
"[bridge] sending message via WebSocket transport",
{ model: bridgeModel }
)
ws.send(
JSON.stringify({
type: "chat.send",
id: messageId,
trigger: options.trigger,
messages: options.messages,
model: bridgeModel ?? undefined,
context: {
currentPage:
options.headers instanceof Headers
? options.headers.get("x-current-page")
: options.headers?.["x-current-page"] ??
"/dashboard",
timezone:
options.headers instanceof Headers
? options.headers.get("x-timezone")
: options.headers?.["x-timezone"] ??
"UTC",
conversationId: options.chatId,
},
})
)
let currentRunId: string | null = null
return new ReadableStream<UIMessageChunk>({
start: (controller) => {
const onMessage = (event: MessageEvent) => {
const raw: unknown = JSON.parse(
String(event.data),
)
if (!isBridgeServerMessage(raw)) return
const msg = raw
switch (msg.type) {
case "chat.ack":
currentRunId = msg.runId
break
case "chunk":
if (msg.runId === currentRunId) {
controller.enqueue(msg.chunk)
}
break
case "chat.done":
if (msg.runId === currentRunId) {
ws.removeEventListener(
"message",
onMessage
)
controller.close()
}
break
case "chat.error":
if (msg.runId === currentRunId) {
ws.removeEventListener(
"message",
onMessage
)
controller.error(
new Error(msg.error)
)
}
break
default:
break
}
}
ws.addEventListener("message", onMessage)
if (options.abortSignal) {
options.abortSignal.addEventListener(
"abort",
() => {
if (currentRunId) {
ws.send(
JSON.stringify({
type: "chat.abort",
runId: currentRunId,
})
)
}
ws.removeEventListener(
"message",
onMessage
)
controller.close()
},
{ once: true }
)
}
},
})
}
reconnectToStream: ChatTransport<UIMessage>["reconnectToStream"] =
async () => {
return null
}
disconnect(): void {
if (this.ws) {
this.ws.close()
this.ws = null
this.authenticated = false
}
}
}
// detect if bridge daemon is running
export async function detectBridge( export async function detectBridge(
url = DEFAULT_URL url = DEFAULT_URL
): Promise<boolean> { ): Promise<boolean> {