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:
parent
50c7d1d1e4
commit
7f5efb84e2
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,6 +22,7 @@ dist/
|
||||
# misc
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
*.png
|
||||
|
||||
# dev tools
|
||||
.playwright-mcp
|
||||
|
||||
5
bun.lock
5
bun.lock
@ -89,6 +89,7 @@
|
||||
"frappe-gantt": "^1.0.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"isomorphic-dompurify": "^3.0.0-rc.2",
|
||||
"jose": "^6.1.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"marked": "^17.0.2",
|
||||
"motion": "^12.33.0",
|
||||
@ -1925,7 +1926,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@ -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/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=="],
|
||||
|
||||
|
||||
10
drizzle/0027_flowery_hulk.sql
Normal file
10
drizzle/0027_flowery_hulk.sql
Normal 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
|
||||
);
|
||||
5097
drizzle/meta/0027_snapshot.json
Normal file
5097
drizzle/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -190,6 +190,13 @@
|
||||
"when": 1771215013379,
|
||||
"tag": "0026_easy_professor_monster",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "6",
|
||||
"when": 1771282232152,
|
||||
"tag": "0027_flowery_hulk",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -32,7 +32,6 @@
|
||||
"test:e2e:desktop": "TAURI=true playwright test --project=desktop-chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.74",
|
||||
"@capacitor/android": "^8.0.2",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/camera": "^8.0.0",
|
||||
@ -59,7 +58,6 @@
|
||||
"@json-render/core": "^0.4.0",
|
||||
"@json-render/react": "^0.4.0",
|
||||
"@opennextjs/cloudflare": "^1.14.4",
|
||||
"@openrouter/ai-sdk-provider": "^2.1.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
@ -103,7 +101,6 @@
|
||||
"@workos-inc/authkit-nextjs": "^2.13.0",
|
||||
"@workos-inc/node": "^8.1.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.73",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -116,6 +113,7 @@
|
||||
"frappe-gantt": "^1.0.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"isomorphic-dompurify": "^3.0.0-rc.2",
|
||||
"jose": "^6.1.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"marked": "^17.0.2",
|
||||
"motion": "^12.33.0",
|
||||
|
||||
14
packages/agent-server/.env.example
Normal file
14
packages/agent-server/.env.example
Normal 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
6
packages/agent-server/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
103
packages/agent-server/README.md
Normal file
103
packages/agent-server/README.md
Normal 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
|
||||
71
packages/agent-server/bun.lock
Normal file
71
packages/agent-server/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
23
packages/agent-server/package.json
Normal file
23
packages/agent-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
61
packages/agent-server/src/auth.ts
Normal file
61
packages/agent-server/src/auth.ts
Normal 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}` }
|
||||
}
|
||||
}
|
||||
43
packages/agent-server/src/config.ts
Normal file
43
packages/agent-server/src/config.ts
Normal 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()
|
||||
155
packages/agent-server/src/index.ts
Normal file
155
packages/agent-server/src/index.ts
Normal 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(", ")}`)
|
||||
27
packages/agent-server/src/mcp/api-client.ts
Normal file
27
packages/agent-server/src/mcp/api-client.ts
Normal 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>
|
||||
}
|
||||
33
packages/agent-server/src/mcp/compass-server.ts
Normal file
33
packages/agent-server/src/mcp/compass-server.ts
Normal 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),
|
||||
]
|
||||
})
|
||||
}
|
||||
97
packages/agent-server/src/mcp/dashboard-tools.ts
Normal file
97
packages/agent-server/src/mcp/dashboard-tools.ts
Normal 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) }]
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
39
packages/agent-server/src/mcp/data-tools.ts
Normal file
39
packages/agent-server/src/mcp/data-tools.ts
Normal 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) }]
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
98
packages/agent-server/src/mcp/github-tools.ts
Normal file
98
packages/agent-server/src/mcp/github-tools.ts
Normal 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) }]
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
14
packages/agent-server/src/mcp/index.ts
Normal file
14
packages/agent-server/src/mcp/index.ts
Normal 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"
|
||||
58
packages/agent-server/src/mcp/memory-tools.ts
Normal file
58
packages/agent-server/src/mcp/memory-tools.ts
Normal 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) }]
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
188
packages/agent-server/src/mcp/schedule-tools.ts
Normal file
188
packages/agent-server/src/mcp/schedule-tools.ts
Normal 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) }]
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
83
packages/agent-server/src/mcp/skill-tools.ts
Normal file
83
packages/agent-server/src/mcp/skill-tools.ts
Normal 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) }]
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
113
packages/agent-server/src/mcp/theme-tools.ts
Normal file
113
packages/agent-server/src/mcp/theme-tools.ts
Normal 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) }]
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
119
packages/agent-server/src/mcp/ui-tools.ts
Normal file
119
packages/agent-server/src/mcp/ui-tools.ts
Normal 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 ?? {},
|
||||
})
|
||||
}]
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
60
packages/agent-server/src/sessions.ts
Normal file
60
packages/agent-server/src/sessions.ts
Normal 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)
|
||||
419
packages/agent-server/src/stream.ts
Normal file
419
packages/agent-server/src/stream.ts
Normal 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()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
16
packages/agent-server/tsconfig.json
Normal file
16
packages/agent-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
3
public/providers/claude.svg
Normal file
3
public/providers/claude.svg
Normal 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 |
6
public/providers/ollama.svg
Normal file
6
public/providers/ollama.svg
Normal 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 |
56
src/app/actions/agent-auth.ts
Normal file
56
src/app/actions/agent-auth.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -518,9 +518,7 @@ export async function updateModelPolicy(
|
||||
.where(eq(agentConfig.id, "global"))
|
||||
.run()
|
||||
} else {
|
||||
const {
|
||||
DEFAULT_MODEL_ID,
|
||||
} = await import("@/lib/agent/provider")
|
||||
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
|
||||
await db
|
||||
.insert(agentConfig)
|
||||
.values({
|
||||
|
||||
436
src/app/actions/provider-config.ts
Normal file
436
src/app/actions/provider-config.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
import { streamText } from "ai"
|
||||
import { getAgentModel } from "@/lib/agent/provider"
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
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({
|
||||
customRules: [
|
||||
@ -53,6 +57,31 @@ const SYSTEM_PROMPT = compassCatalog.prompt({
|
||||
|
||||
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(
|
||||
req: Request
|
||||
): Promise<Response> {
|
||||
@ -61,7 +90,10 @@ export async function POST(
|
||||
return new Response("Unauthorized", { status: 401 })
|
||||
}
|
||||
|
||||
let body: { prompt?: string; context?: Record<string, unknown> }
|
||||
let body: {
|
||||
prompt?: string
|
||||
context?: Record<string, unknown>
|
||||
}
|
||||
try {
|
||||
body = (await req.json()) as {
|
||||
prompt?: string
|
||||
@ -70,14 +102,20 @@ export async function POST(
|
||||
} catch {
|
||||
return new Response(
|
||||
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 previousSpec = context?.previousSpec as
|
||||
| { root?: string; elements?: Record<string, unknown> }
|
||||
| {
|
||||
root?: string
|
||||
elements?: Record<string, unknown>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const sanitizedPrompt = String(prompt || "").slice(
|
||||
@ -87,15 +125,15 @@ export async function POST(
|
||||
|
||||
let userPrompt = sanitizedPrompt
|
||||
|
||||
// include data context if provided
|
||||
const dataContext = context?.dataContext as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
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 (
|
||||
previousSpec?.root &&
|
||||
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.`
|
||||
}
|
||||
|
||||
const model = await getAgentModel()
|
||||
const { apiKey, modelId } = await getModelConfig()
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
// call OpenRouter directly (OpenAI-compatible streaming)
|
||||
const response = await fetch(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
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,
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
148
src/app/api/compass/dashboards/route.ts
Normal file
148
src/app/api/compass/dashboards/route.ts
Normal 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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
290
src/app/api/compass/github/route.ts
Normal file
290
src/app/api/compass/github/route.ts
Normal 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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
132
src/app/api/compass/memory/route.ts
Normal file
132
src/app/api/compass/memory/route.ts
Normal 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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
170
src/app/api/compass/provider/route.ts
Normal file
170
src/app/api/compass/provider/route.ts
Normal 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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
306
src/app/api/compass/query/route.ts
Normal file
306
src/app/api/compass/query/route.ts
Normal 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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
686
src/app/api/compass/schedule/route.ts
Normal file
686
src/app/api/compass/schedule/route.ts
Normal 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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
153
src/app/api/compass/skills/route.ts
Normal file
153
src/app/api/compass/skills/route.ts
Normal 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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
337
src/app/api/compass/themes/route.ts
Normal file
337
src/app/api/compass/themes/route.ts
Normal 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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type UIMessage } from "ai"
|
||||
import { useUIStream, type Spec } from "@json-render/react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import {
|
||||
@ -11,8 +10,8 @@ import {
|
||||
} from "@/app/actions/agent"
|
||||
import { getTextFromParts } from "@/lib/agent/chat-adapter"
|
||||
import { useCompassChat } from "@/hooks/use-compass-chat"
|
||||
import type { AgentMessage } from "@/lib/agent/message-types"
|
||||
import {
|
||||
WebSocketChatTransport,
|
||||
detectBridge,
|
||||
} from "@/lib/agent/ws-transport"
|
||||
import {
|
||||
@ -47,11 +46,11 @@ export function useChatPanel(): PanelContextValue {
|
||||
// --- Chat state context ---
|
||||
|
||||
interface ChatStateValue {
|
||||
readonly messages: ReadonlyArray<UIMessage>
|
||||
readonly messages: ReadonlyArray<AgentMessage>
|
||||
setMessages: (
|
||||
messages:
|
||||
| UIMessage[]
|
||||
| ((prev: UIMessage[]) => UIMessage[])
|
||||
| AgentMessage[]
|
||||
| ((prev: AgentMessage[]) => AgentMessage[])
|
||||
) => void
|
||||
sendMessage: (params: { text: string }) => void
|
||||
regenerate: () => void
|
||||
@ -148,7 +147,7 @@ export function useAgentOptional(): PanelContextValue | null {
|
||||
// --- Helper: extract generateUI output from parts ---
|
||||
|
||||
function findGenerateUIOutput(
|
||||
parts: ReadonlyArray<unknown>,
|
||||
parts: ReadonlyArray<AgentMessage["parts"][number]>,
|
||||
dispatched: Set<string>
|
||||
): {
|
||||
renderPrompt: string
|
||||
@ -156,24 +155,13 @@ function findGenerateUIOutput(
|
||||
callId: string
|
||||
} | null {
|
||||
for (const part of parts) {
|
||||
const p = part as Record<string, unknown>
|
||||
const pType = p.type as string | undefined
|
||||
// only check tool-result parts
|
||||
if (part.type !== "tool-result") continue
|
||||
|
||||
// handle both static tool parts (tool-<name>)
|
||||
// and dynamic tool parts (dynamic-tool)
|
||||
const isToolPart =
|
||||
typeof pType === "string" &&
|
||||
(pType.startsWith("tool-") ||
|
||||
pType === "dynamic-tool")
|
||||
if (!isToolPart) continue
|
||||
const callId = part.toolCallId
|
||||
if (dispatched.has(callId)) continue
|
||||
|
||||
const state = p.state as string | undefined
|
||||
if (state !== "output-available") continue
|
||||
|
||||
const callId = p.toolCallId as string | undefined
|
||||
if (!callId || dispatched.has(callId)) continue
|
||||
|
||||
const output = p.output as
|
||||
const output = part.result as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
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(() => {
|
||||
if (bridge.bridgeConnected && bridge.bridgeEnabled) {
|
||||
return new WebSocketChatTransport()
|
||||
}
|
||||
// if (bridge.bridgeConnected && bridge.bridgeEnabled) {
|
||||
// return new WebSocketChatTransport()
|
||||
// }
|
||||
return null
|
||||
}, [bridge.bridgeConnected, bridge.bridgeEnabled])
|
||||
|
||||
@ -256,14 +245,9 @@ export function ChatProvider({
|
||||
const serialized = finalMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: getTextFromParts(
|
||||
m.parts as ReadonlyArray<{
|
||||
type: string
|
||||
text?: string
|
||||
}>
|
||||
),
|
||||
content: getTextFromParts(m.parts),
|
||||
parts: m.parts,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
await saveConversation(conversationId, serialized)
|
||||
@ -344,7 +328,7 @@ export function ChatProvider({
|
||||
if (!lastMsg || lastMsg.role !== "assistant") return
|
||||
|
||||
const result = findGenerateUIOutput(
|
||||
lastMsg.parts as ReadonlyArray<unknown>,
|
||||
lastMsg.parts,
|
||||
renderDispatchedRef.current
|
||||
)
|
||||
if (!result) return
|
||||
@ -525,14 +509,15 @@ export function ChatProvider({
|
||||
|
||||
setConversationId(lastConv.id)
|
||||
|
||||
const restored: UIMessage[] = msgResult.data.map(
|
||||
const restored: AgentMessage[] = msgResult.data.map(
|
||||
(m) => ({
|
||||
id: m.id,
|
||||
role: m.role as "user" | "assistant",
|
||||
parts:
|
||||
(m.parts as UIMessage["parts"]) ?? [
|
||||
(m.parts as AgentMessage["parts"]) ?? [
|
||||
{ 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
|
||||
for (const m of restored) {
|
||||
if (m.role !== "assistant") continue
|
||||
const parts = m.parts as ReadonlyArray<unknown>
|
||||
let result = findGenerateUIOutput(
|
||||
parts,
|
||||
m.parts,
|
||||
renderDispatchedRef.current
|
||||
)
|
||||
while (result) {
|
||||
renderDispatchedRef.current.add(result.callId)
|
||||
result = findGenerateUIOutput(
|
||||
parts,
|
||||
m.parts,
|
||||
renderDispatchedRef.current
|
||||
)
|
||||
}
|
||||
|
||||
@ -20,14 +20,7 @@ import {
|
||||
IconAlertCircle,
|
||||
IconEye,
|
||||
} from "@tabler/icons-react"
|
||||
import {
|
||||
isTextUIPart,
|
||||
isToolUIPart,
|
||||
isReasoningUIPart,
|
||||
type UIMessage,
|
||||
type ToolUIPart,
|
||||
type DynamicToolUIPart,
|
||||
} from "ai"
|
||||
import type { AgentMessage } from "@/lib/agent/message-types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Reasoning,
|
||||
@ -176,20 +169,18 @@ function friendlyToolName(raw: string): string {
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
readonly msg: UIMessage
|
||||
readonly msg: AgentMessage
|
||||
readonly copiedId: string | null
|
||||
readonly onCopy: (id: string, text: string) => void
|
||||
readonly onRegenerate: () => void
|
||||
readonly isStreaming?: boolean
|
||||
}
|
||||
|
||||
type AnyToolPart = ToolUIPart | DynamicToolUIPart
|
||||
|
||||
function extractToolName(part: AnyToolPart): string {
|
||||
if (part.type === "dynamic-tool") {
|
||||
return part.toolName ?? ""
|
||||
function extractToolName(part: AgentMessage["parts"][number]): string {
|
||||
if (part.type === "tool-call") {
|
||||
return part.toolName
|
||||
}
|
||||
return part.type.slice(5)
|
||||
return ""
|
||||
}
|
||||
|
||||
// renders parts in their natural order from the AI SDK
|
||||
@ -203,8 +194,8 @@ const ChatMessage = memo(
|
||||
}: ChatMessageProps) {
|
||||
if (msg.role === "user") {
|
||||
const text = msg.parts
|
||||
.filter(isTextUIPart)
|
||||
.map((p) => p.text)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => (p as Extract<typeof p, { type: "text" }>).text)
|
||||
.join("")
|
||||
return (
|
||||
<Message from="user">
|
||||
@ -264,19 +255,19 @@ const ChatMessage = memo(
|
||||
for (let i = 0; i < msg.parts.length; i++) {
|
||||
const part = msg.parts[i]
|
||||
|
||||
if (isReasoningUIPart(part)) {
|
||||
if (part.type === "reasoning") {
|
||||
pendingReasoning += part.text
|
||||
reasoningStreaming = part.state === "streaming"
|
||||
continue
|
||||
}
|
||||
|
||||
if (isTextUIPart(part)) {
|
||||
if (part.type === "text") {
|
||||
pendingText += part.text
|
||||
allText += part.text
|
||||
continue
|
||||
}
|
||||
|
||||
if (isToolUIPart(part)) {
|
||||
if (part.type === "tool-call") {
|
||||
sawToolPart = true
|
||||
// flush reasoning accumulated before this tool
|
||||
flushThinking(pendingReasoning, i, reasoningStreaming)
|
||||
@ -284,24 +275,45 @@ const ChatMessage = memo(
|
||||
reasoningStreaming = false
|
||||
// flush text as thinking (not final)
|
||||
flushText(i, false)
|
||||
const tp = part as AnyToolPart
|
||||
const rawName = extractToolName(tp)
|
||||
const rawName = part.toolName
|
||||
|
||||
// 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(
|
||||
<Tool key={tp.toolCallId}>
|
||||
<Tool key={`tool-${part.toolCallId}`}>
|
||||
<ToolHeader
|
||||
title={
|
||||
friendlyToolName(rawName) || "Working"
|
||||
}
|
||||
type={tp.type as ToolUIPart["type"]}
|
||||
state={tp.state}
|
||||
type={"tool-call" as const}
|
||||
state={toolState}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={tp.input} />
|
||||
{(tp.state === "output-available" ||
|
||||
tp.state === "output-error") && (
|
||||
<ToolInput input={part.args} />
|
||||
{resultPart && (
|
||||
<ToolOutput
|
||||
output={tp.output}
|
||||
errorText={tp.errorText}
|
||||
output={resultPart.result}
|
||||
errorText={
|
||||
resultPart.isError
|
||||
? String(resultPart.result)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ToolContent>
|
||||
@ -317,20 +329,6 @@ const ChatMessage = memo(
|
||||
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
|
||||
flushText(msg.parts.length, true)
|
||||
|
||||
|
||||
@ -1,439 +1,282 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
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 { ChevronDown, Check } from "lucide-react"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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"
|
||||
const DEFAULT_PROVIDER = "Alibaba (Qwen)"
|
||||
// ============================================================================
|
||||
// Inline Claude sparkle — rendered directly here to avoid stale HMR
|
||||
// from provider-icon.tsx. This is the ONLY icon the model dropdown needs.
|
||||
// ============================================================================
|
||||
|
||||
// anthropic models available through the bridge
|
||||
const BRIDGE_MODELS = [
|
||||
function ClaudeSparkle({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
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",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "Anthropic",
|
||||
id: "sonnet",
|
||||
name: "Sonnet",
|
||||
description: "Fast and capable",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
provider: "Anthropic",
|
||||
id: "opus",
|
||||
name: "Opus",
|
||||
description: "Most intelligent",
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
name: "Claude Haiku 4.5",
|
||||
provider: "Anthropic",
|
||||
id: "haiku",
|
||||
name: "Haiku",
|
||||
description: "Quick and lightweight",
|
||||
},
|
||||
] as const
|
||||
|
||||
const DEFAULT_BRIDGE_MODEL = BRIDGE_MODELS[0]
|
||||
type AgentModel = (typeof AGENT_MODELS)[number]
|
||||
|
||||
// --- shared state so all instances stay in sync ---
|
||||
|
||||
interface SharedState {
|
||||
readonly display: {
|
||||
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
|
||||
interface ProviderState {
|
||||
providerType: ProviderType
|
||||
model: AgentModel
|
||||
customModelId: string
|
||||
}
|
||||
|
||||
let shared: SharedState = {
|
||||
display: {
|
||||
id: DEFAULT_MODEL_ID,
|
||||
name: DEFAULT_MODEL_NAME,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
},
|
||||
global: {
|
||||
id: DEFAULT_MODEL_ID,
|
||||
name: DEFAULT_MODEL_NAME,
|
||||
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,
|
||||
// ============================================================================
|
||||
// Provider display helpers
|
||||
// ============================================================================
|
||||
|
||||
function providerUsesModelPicker(type: ProviderType): boolean {
|
||||
return (
|
||||
type === "anthropic-oauth" ||
|
||||
type === "anthropic-key" ||
|
||||
type === "openrouter"
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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>()
|
||||
|
||||
function getSnapshot(): SharedState {
|
||||
return shared
|
||||
}
|
||||
|
||||
function setShared(
|
||||
next: Partial<SharedState>
|
||||
): void {
|
||||
shared = { ...shared, ...next }
|
||||
for (const l of listeners) l()
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
listener: () => void
|
||||
): () => void {
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider: string
|
||||
readonly contextLength: number
|
||||
readonly promptCost: string
|
||||
readonly completionCost: string
|
||||
function getSnapshot(): ProviderState {
|
||||
return state
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
readonly provider: string
|
||||
readonly models: ReadonlyArray<ModelInfo>
|
||||
function getServerSnapshot(): ProviderState {
|
||||
return defaultState()
|
||||
}
|
||||
|
||||
function outputCostPerMillion(
|
||||
completionCost: string
|
||||
): number {
|
||||
return parseFloat(completionCost) * 1_000_000
|
||||
function emit(): void {
|
||||
for (const l of listeners) l()
|
||||
}
|
||||
|
||||
function formatOutputCost(
|
||||
completionCost: string
|
||||
): string {
|
||||
const cost = outputCostPerMillion(completionCost)
|
||||
if (cost === 0) return "free"
|
||||
if (cost < 0.01) return "<$0.01/M"
|
||||
return `$${cost.toFixed(2)}/M`
|
||||
function persistState(): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PROVIDER_STORAGE_KEY,
|
||||
JSON.stringify(state)
|
||||
)
|
||||
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 {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const state = React.useSyncExternalStore(
|
||||
const current = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
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(() => {
|
||||
if (state.configLoaded) return
|
||||
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,
|
||||
}
|
||||
|
||||
const stored = loadState()
|
||||
if (
|
||||
canSelect &&
|
||||
prefResult.success &&
|
||||
prefResult.data
|
||||
stored.providerType !== state.providerType ||
|
||||
stored.model.id !== state.model.id
|
||||
) {
|
||||
const prefValid =
|
||||
ceiling === null ||
|
||||
outputCostPerMillion(
|
||||
prefResult.data.completionCost
|
||||
) <= parseFloat(ceiling)
|
||||
state = stored
|
||||
emit()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (prefValid) {
|
||||
const slashIdx =
|
||||
prefResult.data.modelId.indexOf("/")
|
||||
setShared({
|
||||
...base,
|
||||
display: {
|
||||
id: prefResult.data.modelId,
|
||||
name:
|
||||
slashIdx > 0
|
||||
? prefResult.data.modelId.slice(
|
||||
slashIdx + 1
|
||||
)
|
||||
: prefResult.data.modelId,
|
||||
provider: "",
|
||||
},
|
||||
})
|
||||
// hydrate provider type from D1 on mount
|
||||
React.useEffect(() => {
|
||||
import("@/app/actions/provider-config")
|
||||
.then(({ getUserProviderConfig }) => {
|
||||
getUserProviderConfig()
|
||||
.then((result) => {
|
||||
if (!("success" in result) || !result.success)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!result.data) return
|
||||
|
||||
setShared({
|
||||
...base,
|
||||
display: {
|
||||
id: gModelId,
|
||||
name: gModelName,
|
||||
provider: gProvider,
|
||||
},
|
||||
const d = result.data
|
||||
const providerType = (
|
||||
PROVIDER_TYPES.includes(
|
||||
d.providerType as ProviderType
|
||||
)
|
||||
? d.providerType
|
||||
: state.providerType
|
||||
) as ProviderType
|
||||
|
||||
if (providerType !== state.providerType) {
|
||||
updateState({ providerType })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
}, [state.configLoaded])
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || listLoaded || bridgeActive) return
|
||||
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
|
||||
const usesModelPicker = providerUsesModelPicker(
|
||||
current.providerType
|
||||
)
|
||||
})
|
||||
.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
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveProvider(null)
|
||||
setSearch("")
|
||||
}
|
||||
}, [open])
|
||||
const displayName = usesModelPicker
|
||||
? current.model.name
|
||||
: current.customModelId || "Custom"
|
||||
|
||||
const query = search.toLowerCase()
|
||||
const ceiling = state.maxCostPerMillion
|
||||
? 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}`)
|
||||
const handleModelSelect = (m: AgentModel): void => {
|
||||
updateState({ model: m })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// bridge active: show bridge model selector
|
||||
if (bridgeActive) {
|
||||
const handleCustomModelChange = (v: string): void => {
|
||||
updateState({ customModelId: v })
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@ -442,13 +285,13 @@ export function ModelDropdown(): React.JSX.Element {
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs",
|
||||
"hover:bg-muted hover:text-foreground transition-colors",
|
||||
"text-emerald-600 dark:text-emerald-400",
|
||||
"text-muted-foreground",
|
||||
open && "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="max-w-32 truncate">
|
||||
{state.bridgeModel.name}
|
||||
<ClaudeSparkle size={14} />
|
||||
<span className="max-w-36 truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
@ -456,254 +299,57 @@ export function ModelDropdown(): React.JSX.Element {
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-64 p-1"
|
||||
className="w-56 p-1"
|
||||
>
|
||||
<div className="px-2 py-1.5 mb-1">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Claude Code Bridge
|
||||
</p>
|
||||
</div>
|
||||
{BRIDGE_MODELS.map((model) => {
|
||||
const isActive =
|
||||
model.id === state.bridgeModel.id
|
||||
{usesModelPicker ? (
|
||||
<div role="radiogroup" aria-label="Model">
|
||||
{AGENT_MODELS.map((m) => {
|
||||
const isActive = m.id === current.model.id
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleBridgeModelSelect(model)
|
||||
}
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
onClick={() => handleModelSelect(m)}
|
||||
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
|
||||
? "bg-primary/10 ring-1 ring-primary/30"
|
||||
: "hover:bg-muted/70"
|
||||
)}
|
||||
>
|
||||
<ProviderIcon
|
||||
provider="Anthropic"
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="text-xs font-medium flex-1">
|
||||
{model.name}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium">
|
||||
{m.name}
|
||||
</span>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{m.description}
|
||||
</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Check className="h-3 w-3 text-primary shrink-0" />
|
||||
)}
|
||||
</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 className="space-y-1">
|
||||
{filtered.map((group) =>
|
||||
group.models.map((model) => {
|
||||
const isActive =
|
||||
model.id === state.display.id
|
||||
const isSaving =
|
||||
saving === model.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
disabled={
|
||||
isSaving ||
|
||||
saving !== null
|
||||
<div className="p-1.5">
|
||||
<label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1 block">
|
||||
Model ID
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={current.customModelId}
|
||||
onChange={(e) =>
|
||||
handleCustomModelChange(e.target.value)
|
||||
}
|
||||
onClick={() =>
|
||||
handleSelect(model)
|
||||
}
|
||||
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"
|
||||
placeholder="llama3.2"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@ -2,9 +2,31 @@
|
||||
|
||||
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/
|
||||
export const PROVIDER_LOGO: Record<string, string> = {
|
||||
Anthropic: "anthropic",
|
||||
OpenAI: "openai",
|
||||
Google: "google",
|
||||
Meta: "meta",
|
||||
@ -15,6 +37,8 @@ export const PROVIDER_LOGO: Record<string, string> = {
|
||||
Microsoft: "microsoft",
|
||||
Amazon: "amazon",
|
||||
Perplexity: "perplexity",
|
||||
Ollama: "ollama",
|
||||
OpenRouter: "openai",
|
||||
}
|
||||
|
||||
const PROVIDER_ABBR: Record<string, string> = {
|
||||
@ -31,7 +55,7 @@ function getProviderAbbr(name: string): string {
|
||||
}
|
||||
|
||||
export function hasLogo(provider: string): boolean {
|
||||
return provider in PROVIDER_LOGO
|
||||
return provider in PROVIDER_LOGO || provider in INLINE_ICONS
|
||||
}
|
||||
|
||||
export function ProviderIcon({
|
||||
@ -43,8 +67,13 @@ export function ProviderIcon({
|
||||
readonly size?: number
|
||||
readonly className?: string
|
||||
}): 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) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { UIMessage } from "ai"
|
||||
import type { UIMessage } from "./types"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { ToolUIPart } from "ai"
|
||||
import type { ToolUIPart } from "./types"
|
||||
import { CheckIcon, XIcon } from "lucide-react"
|
||||
import { type ComponentProps, createContext, type ReactNode, useContext } from "react"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { LanguageModelUsage } from "ai"
|
||||
import type { LanguageModelUsage } from "./types"
|
||||
import { type ComponentProps, createContext, useContext } from "react"
|
||||
import { getUsage } from "tokenlens"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Experimental_GeneratedImage } from "ai"
|
||||
import type { GeneratedImage } from "./types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
export type ImageProps = GeneratedImage & {
|
||||
className?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { FileUIPart, UIMessage } from "ai"
|
||||
import type { FileUIPart, UIMessage } from "./types"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from "lucide-react"
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { ChatStatus, FileUIPart } from "ai"
|
||||
import type { ChatStatus, FileUIPart } from "./types"
|
||||
import {
|
||||
CornerDownLeftIcon,
|
||||
ImageIcon,
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
"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 {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
@ -21,12 +30,12 @@ export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
|
||||
export interface ToolHeaderProps {
|
||||
title?: string
|
||||
type: ToolUIPart["type"]
|
||||
state: ToolUIPart["state"]
|
||||
type: string
|
||||
state: ToolState
|
||||
className?: string
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: ToolUIPart["state"]): ReactNode => {
|
||||
const getStatusIcon = (status: ToolState): ReactNode => {
|
||||
switch (status) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
@ -41,8 +50,11 @@ const getStatusIcon = (status: ToolUIPart["state"]): ReactNode => {
|
||||
}
|
||||
}
|
||||
|
||||
const isInProgress = (status: ToolUIPart["state"]): boolean =>
|
||||
status === "input-streaming" || status === "input-available"
|
||||
const isInProgress = (status: ToolState): boolean =>
|
||||
status === "input-streaming" ||
|
||||
status === "input-available" ||
|
||||
status === "partial-call" ||
|
||||
status === "call"
|
||||
|
||||
export const ToolHeader = ({ className, title, type, state, ...props }: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
@ -74,7 +86,7 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
)
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolUIPart["input"]
|
||||
input: unknown
|
||||
}
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
@ -89,8 +101,8 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
)
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolUIPart["output"]
|
||||
errorText: ToolUIPart["errorText"]
|
||||
output: unknown
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => {
|
||||
|
||||
57
src/components/ai/types.ts
Normal file
57
src/components/ai/types.ts
Normal 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
|
||||
}
|
||||
@ -5,6 +5,9 @@ import {
|
||||
Check,
|
||||
Loader2,
|
||||
Search,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Bar,
|
||||
@ -39,6 +42,11 @@ import {
|
||||
getUsageMetrics,
|
||||
updateModelPolicy,
|
||||
} from "@/app/actions/ai-config"
|
||||
import {
|
||||
getUserProviderConfig,
|
||||
setUserProviderConfig,
|
||||
clearUserProviderConfig,
|
||||
} from "@/app/actions/provider-config"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -46,6 +54,11 @@ import {
|
||||
ProviderIcon,
|
||||
hasLogo,
|
||||
} from "@/components/agent/provider-icon"
|
||||
import { setProviderType } from "@/components/agent/model-dropdown"
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
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 {
|
||||
const perMillion =
|
||||
parseFloat(costPerToken) * 1_000_000
|
||||
@ -134,7 +220,265 @@ function outputCostPerMillion(
|
||||
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({
|
||||
groups,
|
||||
@ -159,7 +503,6 @@ function ModelPicker({
|
||||
|
||||
const query = search.toLowerCase()
|
||||
|
||||
// sort: providers with logos first, then alphabetical
|
||||
const sortedGroups = React.useMemo(() => {
|
||||
return [...groups].sort((a, b) => {
|
||||
const aHas = hasLogo(a.provider) ? 0 : 1
|
||||
@ -169,7 +512,6 @@ function ModelPicker({
|
||||
})
|
||||
}, [groups])
|
||||
|
||||
// filter models by search + active provider + cost ceiling
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
return sortedGroups
|
||||
.map((group) => {
|
||||
@ -240,7 +582,6 @@ function ModelPicker({
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="space-y-3">
|
||||
{/* search bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@ -251,9 +592,7 @@ function ModelPicker({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* two-panel layout - no outer border */}
|
||||
<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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@ -315,7 +654,6 @@ function ModelPicker({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* model list */}
|
||||
<div className="flex-1 overflow-y-auto pr-1">
|
||||
{filteredGroups.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||
@ -380,7 +718,6 @@ function ModelPicker({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* save bar */}
|
||||
{isDirty && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@ -407,7 +744,9 @@ function ModelPicker({
|
||||
)
|
||||
}
|
||||
|
||||
// --- usage metrics ---
|
||||
// ============================================================================
|
||||
// Usage section
|
||||
// ============================================================================
|
||||
|
||||
const chartConfig = {
|
||||
tokens: {
|
||||
@ -527,7 +866,9 @@ function UsageSection({
|
||||
)
|
||||
}
|
||||
|
||||
// --- main tab ---
|
||||
// ============================================================================
|
||||
// Main tab
|
||||
// ============================================================================
|
||||
|
||||
export function AIModelTab() {
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
@ -631,6 +972,13 @@ export function AIModelTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Provider configuration — always visible */}
|
||||
<ProviderConfigSection
|
||||
onProviderChanged={loadData}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Active Model</Label>
|
||||
{activeConfig ? (
|
||||
|
||||
@ -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
|
||||
export const agentUsage = sqliteTable("agent_usage", {
|
||||
id: text("id").primaryKey(),
|
||||
@ -65,6 +81,8 @@ export const agentUsage = sqliteTable("agent_usage", {
|
||||
|
||||
export type AgentConfig = typeof agentConfig.$inferSelect
|
||||
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 NewAgentUsage = typeof agentUsage.$inferInsert
|
||||
export type UserModelPreference =
|
||||
|
||||
376
src/hooks/use-agent.ts
Normal file
376
src/hooks/use-agent.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -2,12 +2,6 @@
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
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 {
|
||||
initializeActionHandlers,
|
||||
@ -15,40 +9,16 @@ import {
|
||||
dispatchToolActions,
|
||||
ALL_HANDLER_TYPES,
|
||||
} from "@/lib/agent/chat-adapter"
|
||||
import { useAgent } from "@/hooks/use-agent"
|
||||
import type { AgentMessage } from "@/lib/agent/message-types"
|
||||
|
||||
interface UseCompassChatOptions {
|
||||
readonly conversationId?: string | null
|
||||
readonly onFinish?: (params: {
|
||||
messages: ReadonlyArray<UIMessage>
|
||||
messages: ReadonlyArray<AgentMessage>
|
||||
}) => void | Promise<void>
|
||||
readonly openPanel?: () => void
|
||||
readonly bridgeTransport?:
|
||||
| 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)
|
||||
}
|
||||
readonly bridgeTransport?: unknown | null // placeholder for future bridge integration
|
||||
}
|
||||
|
||||
export function useCompassChat(options?: UseCompassChatOptions) {
|
||||
@ -62,68 +32,46 @@ export function useCompassChat(options?: UseCompassChatOptions) {
|
||||
|
||||
const dispatchedRef = useRef(new Set<string>())
|
||||
|
||||
const bridgeRef = useRef(options?.bridgeTransport)
|
||||
bridgeRef.current = options?.bridgeTransport
|
||||
|
||||
const defaultTransport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport({
|
||||
api: "/api/agent",
|
||||
headers: {
|
||||
"x-current-page": pathname,
|
||||
"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)
|
||||
},
|
||||
// use the new agent hook
|
||||
const agent = useAgent({
|
||||
agentServerUrl: "http://localhost:3001",
|
||||
sessionId: options?.conversationId ?? undefined,
|
||||
currentPage: pathname,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
onFinish: options?.onFinish
|
||||
? (messages) => options.onFinish?.({ messages })
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const isGenerating =
|
||||
chatState.status === "streaming" ||
|
||||
chatState.status === "submitted"
|
||||
agent.status === "streaming"
|
||||
|
||||
// dispatch tool-based client actions on new messages
|
||||
useEffect(() => {
|
||||
const last = chatState.messages.at(-1)
|
||||
const last = agent.messages.at(-1)
|
||||
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(
|
||||
last.parts as ReadonlyArray<Record<string, unknown>>,
|
||||
toolParts as ReadonlyArray<Record<string, unknown>>,
|
||||
dispatchedRef.current
|
||||
)
|
||||
}, [chatState.messages])
|
||||
}, [agent.messages])
|
||||
|
||||
// initialize action handlers
|
||||
useEffect(() => {
|
||||
@ -159,13 +107,13 @@ export function useCompassChat(options?: UseCompassChatOptions) {
|
||||
}, [])
|
||||
|
||||
return {
|
||||
messages: chatState.messages,
|
||||
setMessages: chatState.setMessages,
|
||||
sendMessage: chatState.sendMessage,
|
||||
regenerate: chatState.regenerate,
|
||||
stop: chatState.stop,
|
||||
status: chatState.status,
|
||||
error: chatState.error,
|
||||
messages: agent.messages,
|
||||
setMessages: agent.setMessages,
|
||||
sendMessage: agent.sendMessage,
|
||||
regenerate: agent.regenerate,
|
||||
stop: agent.stop,
|
||||
status: agent.status,
|
||||
error: agent.error,
|
||||
isGenerating,
|
||||
pathname,
|
||||
}
|
||||
|
||||
@ -1,77 +1,23 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
|
||||
// the ws-transport module is "use client" and relies on
|
||||
// browser globals (WebSocket, localStorage, window).
|
||||
// we test what we can: the detectBridge timeout logic
|
||||
// and the constructor / getApiKey behavior via mocks.
|
||||
|
||||
describe("WebSocketChatTransport", () => {
|
||||
describe("ws-transport", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("can be imported without throwing", async () => {
|
||||
// mock WebSocket globally so the module loads
|
||||
it("exports BRIDGE_PORT and detectBridge", async () => {
|
||||
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()
|
||||
},
|
||||
)
|
||||
|
||||
const mod = await import("../ws-transport")
|
||||
expect(mod.WebSocketChatTransport).toBeDefined()
|
||||
expect(mod.BRIDGE_PORT).toBe(18789)
|
||||
})
|
||||
|
||||
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
|
||||
expect(typeof mod.detectBridge).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
@ -89,7 +35,6 @@ describe("detectBridge", () => {
|
||||
onerror: (() => void) | null = null
|
||||
onopen: (() => void) | null = null
|
||||
constructor() {
|
||||
// fire error on next tick
|
||||
setTimeout(() => {
|
||||
if (this.onerror) this.onerror()
|
||||
}, 0)
|
||||
@ -141,7 +86,6 @@ describe("detectBridge", () => {
|
||||
close = vi.fn()
|
||||
onerror: (() => 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 promise = detectBridge("ws://localhost:18789")
|
||||
// advance past the 3000ms CONNECT_TIMEOUT
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
const result = await promise
|
||||
expect(result).toBe(false)
|
||||
|
||||
64
src/lib/agent/agent-transport.ts
Normal file
64
src/lib/agent/agent-transport.ts
Normal 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
94
src/lib/agent/api-auth.ts
Normal 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
|
||||
}
|
||||
@ -2,12 +2,14 @@
|
||||
|
||||
// --- Shared utilities ---
|
||||
|
||||
import type { AgentMessage } from "@/lib/agent/message-types"
|
||||
|
||||
export function getTextFromParts(
|
||||
parts: ReadonlyArray<{ type: string; text?: string }>
|
||||
parts: ReadonlyArray<AgentMessage["parts"][number]>
|
||||
): string {
|
||||
return parts
|
||||
.filter(
|
||||
(p): p is { type: "text"; text: string } =>
|
||||
(p): p is Extract<typeof p, { type: "text" }> =>
|
||||
p.type === "text"
|
||||
)
|
||||
.map((p) => p.text)
|
||||
|
||||
@ -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),
|
||||
}),
|
||||
}
|
||||
83
src/lib/agent/message-types.ts
Normal file
83
src/lib/agent/message-types.ts
Normal 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"
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
@ -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
|
||||
}
|
||||
}
|
||||
@ -1,275 +1,13 @@
|
||||
"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 DEFAULT_URL = `ws://localhost:${BRIDGE_PORT}`
|
||||
const AUTH_TIMEOUT = 5000
|
||||
const CONNECT_TIMEOUT = 3000
|
||||
|
||||
function isBridgeServerMessage(
|
||||
raw: unknown,
|
||||
): raw is BridgeServerMessage {
|
||||
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
|
||||
/**
|
||||
* Detect if the bridge daemon is running by attempting
|
||||
* a WebSocket connection and checking if it opens.
|
||||
*/
|
||||
export async function detectBridge(
|
||||
url = DEFAULT_URL
|
||||
): Promise<boolean> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user