Compare commits
10 Commits
4cebbb73e8
...
b80ffee3f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80ffee3f7 | ||
| 0198898979 | |||
| 7d7eb72ea8 | |||
| c53f3a5fac | |||
| 3f8d273986 | |||
| 7ee5304176 | |||
| 4fc952cddd | |||
| feb7dc2643 | |||
| 7f5efb84e2 | |||
| 50c7d1d1e4 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,6 +22,7 @@ dist/
|
||||
# misc
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
*.png
|
||||
|
||||
# dev tools
|
||||
.playwright-mcp
|
||||
|
||||
84
bun.lock
84
bun.lock
@ -5,7 +5,6 @@
|
||||
"": {
|
||||
"name": "dashboard-app-template",
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.74",
|
||||
"@capacitor/android": "^8.0.2",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/camera": "^8.0.0",
|
||||
@ -31,8 +30,8 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@json-render/core": "^0.4.0",
|
||||
"@json-render/react": "^0.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.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",
|
||||
@ -76,7 +75,7 @@
|
||||
"@workos-inc/authkit-nextjs": "^2.13.0",
|
||||
"@workos-inc/node": "^8.1.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.73",
|
||||
"agent-core": "file:packages/agent-core",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -89,6 +88,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",
|
||||
@ -143,16 +143,10 @@
|
||||
"packages": {
|
||||
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2r1Q6azvqMYxQ1hqfWZmWg4+8MajoldD/ty65XdhCaCoBfvDu7trcvxXDfTSU+3/wZ1JIDky46SWYFOHnTbsBw=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og=="],
|
||||
|
||||
"@ai-sdk/react": ["@ai-sdk/react@3.0.74", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.13", "ai": "6.0.72", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-L8N9HNM9Vt3rxORhX6+KCrsYRI6ZXGz1q8o/ysw6+Sx3MC0pqSZLiaKYifIYe2TSWgLP5mWcGlA5hHPuq5Jdfw=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.8", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.5" } }, "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ=="],
|
||||
@ -463,6 +457,8 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
@ -563,6 +559,8 @@
|
||||
|
||||
"@json-render/react": ["@json-render/react@0.4.0", "", { "dependencies": { "@json-render/core": "0.4.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-OAXdWdOrAXHEFzpEF7xV+84D00JEmLMKlt5u0wc+C/P+q4q6TnpAWx2j28PBpDB2mpidkW9VnTUM+SCH9J8Lrw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@next/env": ["@next/env@15.5.9", "", {}, "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg=="],
|
||||
@ -609,8 +607,6 @@
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.15.0", "", { "dependencies": { "@ast-grep/napi": "0.40.0", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.11", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "^14.2.35 || ~15.0.7 || ~15.1.11 || ~15.2.8 || ~15.3.8 || ~15.4.10 || ~15.5.9 || ^16.0.10", "wrangler": "^4.59.2" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-AZPaqk25XUBxtdkfjUZQBbY3ovifVLC4GgSRHuejqsIWfv8KjTRNFVdaCaaPmbLkrgymqxNhkbfJS5sD28AK/g=="],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.1.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-UypPbVnSExxmG/4Zg0usRiit3auvQVrjUXSyEhm0sZ9GQnW/d8p/bKgCk2neh1W5YyRSo7PNQvCrAEBHZnqQkQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
|
||||
@ -1253,8 +1249,6 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
|
||||
@ -1289,12 +1283,14 @@
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"agent-core": ["agent-core@file:packages/agent-core", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "zod": "^3.24.1" }, "devDependencies": { "bun-types": "^1.3.9" } }],
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@6.0.73", "", { "dependencies": { "@ai-sdk/gateway": "3.0.36", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-p2/ICXIjAM4+bIFHEkAB+l58zq+aTmxAkotsb6doNt/CEms72zt6gxv2ky1fQDwU4ecMOcmMh78VJUSEKECzlg=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@ -1379,6 +1375,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
@ -1439,10 +1437,12 @@
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
@ -1649,6 +1649,8 @@
|
||||
|
||||
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
@ -1659,6 +1661,8 @@
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
@ -1671,6 +1675,8 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@4.2.5", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
@ -1791,6 +1797,8 @@
|
||||
|
||||
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||
|
||||
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
@ -1833,6 +1841,8 @@
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
|
||||
@ -1925,7 +1935,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=="],
|
||||
|
||||
@ -1935,10 +1945,12 @@
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
"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=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
||||
@ -2243,6 +2255,8 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
@ -2525,8 +2539,6 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"swr": ["swr@2.4.0", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
@ -2543,8 +2555,6 @@
|
||||
|
||||
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
|
||||
|
||||
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
|
||||
|
||||
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
@ -2581,6 +2591,8 @@
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"ts-tqdm": ["ts-tqdm@0.8.6", "", {}, "sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ=="],
|
||||
@ -2743,12 +2755,12 @@
|
||||
|
||||
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@ai-sdk/react/ai": ["ai@6.0.72", "", { "dependencies": { "@ai-sdk/gateway": "3.0.35", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D3TzDX6LzYL8qwi1A0rLnmuUexqDcCu4LSg77hcDHsqNRkaGspGItkz1U3RnN3ojv31XQYI9VmoWpkj44uvIUA=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug=="],
|
||||
@ -3275,10 +3287,14 @@
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||
|
||||
"@opennextjs/aws/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@opennextjs/aws/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
@ -3401,7 +3417,11 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"agent-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
@ -3431,8 +3451,6 @@
|
||||
|
||||
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
@ -3441,8 +3459,6 @@
|
||||
|
||||
"glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"iron-session/iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
|
||||
|
||||
"jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
|
||||
@ -3515,7 +3531,7 @@
|
||||
|
||||
"yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"@ai-sdk/react/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9aRTVM1P1u4yUIjBpco/WCF1WXr/DgWKuDYgLLHdENS8kiEuxDOPJuGbc/6+7EwQ6ZqSh0UOgeqvHfGJfU23Qg=="],
|
||||
"youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
|
||||
|
||||
@ -3979,6 +3995,8 @@
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@node-minify/core/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
|
||||
|
||||
"@node-minify/core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
|
||||
@ -4017,6 +4035,8 @@
|
||||
|
||||
"@workos-inc/authkit-nextjs/@workos-inc/node/jose": ["jose@5.6.3", "", {}, "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g=="],
|
||||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
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
|
||||
);
|
||||
33
drizzle/0028_small_old_lace.sql
Normal file
33
drizzle/0028_small_old_lace.sql
Normal file
@ -0,0 +1,33 @@
|
||||
CREATE TABLE `organization_invites` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`code` text NOT NULL,
|
||||
`role` text DEFAULT 'office' NOT NULL,
|
||||
`max_uses` integer,
|
||||
`use_count` integer DEFAULT 0 NOT NULL,
|
||||
`expires_at` text,
|
||||
`created_by` text NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `organization_invites_code_unique` ON `organization_invites` (`code`);--> statement-breakpoint
|
||||
CREATE TABLE `mcp_servers` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`org_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`slug` text NOT NULL,
|
||||
`transport` text NOT NULL,
|
||||
`command` text,
|
||||
`args` text,
|
||||
`env` text,
|
||||
`url` text,
|
||||
`headers` text,
|
||||
`is_enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`created_by` text NOT NULL,
|
||||
FOREIGN KEY (`org_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
8
drizzle/0029_fantastic_mach_iv.sql
Normal file
8
drizzle/0029_fantastic_mach_iv.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TABLE `anthropic_oauth_tokens` (
|
||||
`user_id` text PRIMARY KEY NOT NULL,
|
||||
`access_token` text NOT NULL,
|
||||
`refresh_token` text NOT NULL,
|
||||
`expires_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
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
5344
drizzle/meta/0028_snapshot.json
Normal file
5344
drizzle/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5403
drizzle/meta/0029_snapshot.json
Normal file
5403
drizzle/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -190,6 +190,27 @@
|
||||
"when": 1771215013379,
|
||||
"tag": "0026_easy_professor_monster",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "6",
|
||||
"when": 1771282232152,
|
||||
"tag": "0027_flowery_hulk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "6",
|
||||
"when": 1771295883108,
|
||||
"tag": "0028_small_old_lace",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "6",
|
||||
"when": 1771303716734,
|
||||
"tag": "0029_fantastic_mach_iv",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ["agent-core"],
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
@ -26,8 +27,19 @@ export default nextConfig;
|
||||
// Enable calling `getCloudflareContext()` in `next dev`.
|
||||
// See https://opennext.js.org/cloudflare/bindings#local-access-to-bindings.
|
||||
// Only init in dev -- build and lint don't need the wrangler proxy.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// Disabled for local dev without Cloudflare account access:
|
||||
if (process.env.NODE_ENV === "development" && process.env.ENABLE_CF_DEV === "1") {
|
||||
import("@opennextjs/cloudflare").then((mod) =>
|
||||
mod.initOpenNextCloudflareForDev()
|
||||
);
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
// When Cloudflare dev proxy is not enabled, set a mock context so
|
||||
// getCloudflareContext() doesn't throw. Actions check `env?.DB` and
|
||||
// gracefully return empty data when it's missing.
|
||||
const sym = Symbol.for("__cloudflare-context__");
|
||||
(globalThis as Record<symbol, unknown>)[sym] = {
|
||||
env: {}, // no DB binding — actions will short-circuit
|
||||
cf: {},
|
||||
ctx: { waitUntil: () => {}, passThroughOnException: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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",
|
||||
@ -58,8 +57,8 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@json-render/core": "^0.4.0",
|
||||
"@json-render/react": "^0.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.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 +102,7 @@
|
||||
"@workos-inc/authkit-nextjs": "^2.13.0",
|
||||
"@workos-inc/node": "^8.1.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.73",
|
||||
"agent-core": "file:packages/agent-core",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -116,6 +115,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",
|
||||
|
||||
214
packages/agent-core/bun.lock
Normal file
214
packages/agent-core/bun.lock
Normal file
@ -0,0 +1,214 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "agent-core",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.74.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"zod": "^3.24.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.3.9",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@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=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
}
|
||||
}
|
||||
14
packages/agent-core/package.json
Normal file
14
packages/agent-core/package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "agent-core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.74.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.3.9"
|
||||
}
|
||||
}
|
||||
57
packages/agent-core/src/client.ts
Normal file
57
packages/agent-core/src/client.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import Anthropic from "@anthropic-ai/sdk"
|
||||
import type { ProviderConfig } from "./types"
|
||||
|
||||
export function createClient(provider: ProviderConfig): Anthropic {
|
||||
switch (provider.type) {
|
||||
case "anthropic": {
|
||||
// OAuth tokens use Bearer auth instead of x-api-key
|
||||
if (provider.apiKey?.startsWith("sk-ant-oat")) {
|
||||
const oauthToken = provider.apiKey
|
||||
const wrappedFetch: typeof globalThis.fetch = (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
const betaUrl = url.includes("?")
|
||||
? `${url}&beta=true`
|
||||
: `${url}?beta=true`
|
||||
const existingHeaders =
|
||||
(init?.headers as Record<string, string> | undefined) ?? {}
|
||||
// Remove x-api-key if SDK sets it, add Bearer
|
||||
const { "x-api-key": _dropped, ...rest } = existingHeaders
|
||||
return globalThis.fetch(betaUrl, {
|
||||
...init,
|
||||
headers: {
|
||||
...rest,
|
||||
authorization: `Bearer ${oauthToken}`,
|
||||
"anthropic-beta":
|
||||
"oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Anthropic({ apiKey: "unused", fetch: wrappedFetch })
|
||||
}
|
||||
return new Anthropic({
|
||||
apiKey: provider.apiKey,
|
||||
...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
|
||||
})
|
||||
}
|
||||
case "openrouter":
|
||||
return new Anthropic({
|
||||
apiKey: provider.apiKey ?? "",
|
||||
baseURL: "https://openrouter.ai/api",
|
||||
})
|
||||
case "ollama":
|
||||
return new Anthropic({
|
||||
apiKey: "ollama",
|
||||
baseURL: provider.baseUrl ?? "http://localhost:11434",
|
||||
})
|
||||
case "custom":
|
||||
return new Anthropic({
|
||||
apiKey: provider.apiKey ?? "",
|
||||
...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
27
packages/agent-core/src/index.ts
Normal file
27
packages/agent-core/src/index.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export { createClient } from "./client"
|
||||
export { runAgent } from "./loop"
|
||||
export { createTools } from "./tools"
|
||||
export type { ToolDef } from "./tools"
|
||||
export { zodToJsonSchema } from "./tools"
|
||||
export { buildSystemPrompt } from "./system-prompt"
|
||||
export { sseEncode, createSSEStream } from "./stream-helpers"
|
||||
export {
|
||||
createCompassServer,
|
||||
createClientManager,
|
||||
} from "./mcp"
|
||||
export type {
|
||||
McpServerConfig,
|
||||
McpClientManager,
|
||||
} from "./mcp"
|
||||
export type {
|
||||
ProviderConfig,
|
||||
AgentContext,
|
||||
DataSource,
|
||||
SSEData,
|
||||
} from "./types"
|
||||
export {
|
||||
generatePKCE,
|
||||
buildAuthUrl,
|
||||
exchangeCode,
|
||||
refreshAccessToken,
|
||||
} from "./oauth"
|
||||
248
packages/agent-core/src/loop.ts
Normal file
248
packages/agent-core/src/loop.ts
Normal file
@ -0,0 +1,248 @@
|
||||
import type Anthropic from "@anthropic-ai/sdk"
|
||||
import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages"
|
||||
import { createClient } from "./client"
|
||||
import type { ProviderConfig, SSEData } from "./types"
|
||||
import type { ToolDef } from "./tools"
|
||||
import type { McpClientManager } from "./mcp/types"
|
||||
|
||||
interface AgentOptions {
|
||||
readonly provider: ProviderConfig
|
||||
readonly model: string
|
||||
readonly systemPrompt: string
|
||||
readonly messages: ReadonlyArray<{
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}>
|
||||
readonly tools?: readonly ToolDef[]
|
||||
readonly mcpClientManager?: McpClientManager
|
||||
readonly maxTurns?: number
|
||||
readonly isOAuth?: boolean
|
||||
}
|
||||
|
||||
export async function* runAgent(
|
||||
opts: AgentOptions
|
||||
): AsyncGenerator<SSEData> {
|
||||
const client = createClient(opts.provider)
|
||||
const maxTurns = opts.maxTurns ?? 25
|
||||
|
||||
// Mutable messages array for the agentic loop
|
||||
const messages: Anthropic.MessageParam[] = opts.messages.map(
|
||||
(m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})
|
||||
)
|
||||
|
||||
// Build tool map for execution and API tool definitions
|
||||
const toolMap = new Map<
|
||||
string,
|
||||
(input: unknown) => Promise<string> | string
|
||||
>()
|
||||
const apiTools: Tool[] = []
|
||||
|
||||
// Register direct tools first (they take priority)
|
||||
if (opts.tools) {
|
||||
for (const tool of opts.tools) {
|
||||
toolMap.set(tool.name, tool.run)
|
||||
apiTools.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema:
|
||||
tool.input_schema as Tool.InputSchema,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register MCP tools (skip if already provided as direct)
|
||||
const mcpManager = opts.mcpClientManager
|
||||
if (mcpManager) {
|
||||
for (const tool of mcpManager.listTools()) {
|
||||
if (toolMap.has(tool.name)) continue
|
||||
apiTools.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema:
|
||||
tool.input_schema as Tool.InputSchema,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth endpoint requires mcp_ prefix on tool names
|
||||
const effectiveTools: Tool[] = opts.isOAuth
|
||||
? apiTools.map((t) => ({ ...t, name: `mcp_${t.name}` }))
|
||||
: apiTools
|
||||
|
||||
let turn = 0
|
||||
|
||||
while (turn < maxTurns) {
|
||||
turn++
|
||||
|
||||
try {
|
||||
const stream = client.messages.stream({
|
||||
model: opts.model,
|
||||
max_tokens: 8192,
|
||||
system: opts.systemPrompt,
|
||||
messages,
|
||||
tools: effectiveTools,
|
||||
})
|
||||
|
||||
// Stream text deltas and tool_use starts to the caller
|
||||
for await (const event of stream) {
|
||||
if (event.type === "content_block_start") {
|
||||
const block = event.content_block
|
||||
if (block.type === "tool_use") {
|
||||
const displayName =
|
||||
opts.isOAuth && block.name.startsWith("mcp_")
|
||||
? block.name.slice(4)
|
||||
: block.name
|
||||
yield {
|
||||
type: "tool_use",
|
||||
name: displayName,
|
||||
toolCallId: block.id,
|
||||
input: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "content_block_delta") {
|
||||
const delta = event.delta
|
||||
if (
|
||||
"text" in delta &&
|
||||
delta.type === "text_delta"
|
||||
) {
|
||||
yield { type: "text", content: delta.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = await stream.finalMessage()
|
||||
|
||||
// No tool calls — we're done
|
||||
if (message.stop_reason !== "tool_use") {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: message.content
|
||||
.filter(
|
||||
(b): b is Anthropic.TextBlock =>
|
||||
b.type === "text"
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join(""),
|
||||
usage: message.usage
|
||||
? {
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
totalCostUsd: 0,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Execute tool calls and continue the loop
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message.content,
|
||||
})
|
||||
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = []
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type !== "tool_use") continue
|
||||
|
||||
// Strip mcp_ prefix from OAuth tool calls for local dispatch
|
||||
const toolName =
|
||||
opts.isOAuth && block.name.startsWith("mcp_")
|
||||
? block.name.slice(4)
|
||||
: block.name
|
||||
|
||||
const runFn = toolMap.get(toolName)
|
||||
|
||||
// Route: direct tool -> MCP manager -> unknown
|
||||
if (!runFn && !mcpManager) {
|
||||
const errorResult = JSON.stringify({
|
||||
error: `Unknown tool: ${toolName}`,
|
||||
})
|
||||
yield {
|
||||
type: "tool_result",
|
||||
toolCallId: block.id,
|
||||
output: errorResult,
|
||||
}
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: errorResult,
|
||||
is_error: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
let result: string
|
||||
if (runFn) {
|
||||
result = await runFn(block.input)
|
||||
} else if (mcpManager) {
|
||||
result = await mcpManager.callTool(
|
||||
toolName,
|
||||
block.input
|
||||
)
|
||||
} else {
|
||||
result = JSON.stringify({
|
||||
error: `Unknown tool: ${toolName}`,
|
||||
})
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(result)
|
||||
} catch {
|
||||
parsed = result
|
||||
}
|
||||
yield {
|
||||
type: "tool_result",
|
||||
toolCallId: block.id,
|
||||
output: parsed,
|
||||
}
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: result,
|
||||
})
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
yield {
|
||||
type: "tool_result",
|
||||
toolCallId: block.id,
|
||||
output: { error: errorMsg },
|
||||
}
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: JSON.stringify({ error: errorMsg }),
|
||||
is_error: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({ role: "user", content: toolResults })
|
||||
} catch (err) {
|
||||
yield {
|
||||
type: "error",
|
||||
error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Max turns reached
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Max turns reached",
|
||||
usage: undefined,
|
||||
}
|
||||
}
|
||||
228
packages/agent-core/src/mcp/client-manager.ts
Normal file
228
packages/agent-core/src/mcp/client-manager.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import {
|
||||
StreamableHTTPClientTransport,
|
||||
} from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import type { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import type { McpServerConfig, McpClientManager } from "./types"
|
||||
|
||||
interface ConnectedServer {
|
||||
readonly client: Client
|
||||
readonly toolNames: ReadonlySet<string>
|
||||
}
|
||||
|
||||
interface ToolEntry {
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
readonly input_schema: Record<string, unknown>
|
||||
readonly serverName: string
|
||||
readonly originalName: string
|
||||
}
|
||||
|
||||
const COMPASS_SERVER_NAME = "compass"
|
||||
|
||||
export function createClientManager(
|
||||
compassServer?: Server
|
||||
): McpClientManager {
|
||||
const servers = new Map<string, ConnectedServer>()
|
||||
const toolIndex = new Map<string, { serverName: string; originalName: string }>()
|
||||
let cachedTools: ToolEntry[] = []
|
||||
|
||||
async function connectServer(
|
||||
config: McpServerConfig
|
||||
): Promise<void> {
|
||||
const client = new Client({
|
||||
name: `compass-client-${config.name}`,
|
||||
version: "1.0.0",
|
||||
})
|
||||
|
||||
const transport = config.transport
|
||||
|
||||
if (transport.type === "in-memory") {
|
||||
if (!compassServer) {
|
||||
console.error(
|
||||
"in-memory transport requires compassServer"
|
||||
)
|
||||
return
|
||||
}
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair()
|
||||
await compassServer.connect(serverTransport)
|
||||
await client.connect(clientTransport)
|
||||
} else if (transport.type === "stdio") {
|
||||
const stdioTransport = new StdioClientTransport({
|
||||
command: transport.command,
|
||||
args: transport.args ? [...transport.args] : undefined,
|
||||
env: transport.env
|
||||
? { ...transport.env }
|
||||
: undefined,
|
||||
})
|
||||
await client.connect(stdioTransport)
|
||||
} else if (transport.type === "http") {
|
||||
const httpTransport = new StreamableHTTPClientTransport(
|
||||
new URL(transport.url),
|
||||
transport.headers
|
||||
? {
|
||||
requestInit: {
|
||||
headers: { ...transport.headers },
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
await client.connect(httpTransport)
|
||||
}
|
||||
|
||||
const result = await client.listTools()
|
||||
const toolNames = new Set<string>()
|
||||
|
||||
for (const tool of result.tools) {
|
||||
toolNames.add(tool.name)
|
||||
}
|
||||
|
||||
servers.set(config.name, { client, toolNames })
|
||||
}
|
||||
|
||||
function buildToolIndex(): void {
|
||||
toolIndex.clear()
|
||||
cachedTools = []
|
||||
|
||||
for (const [serverName, server] of servers) {
|
||||
const isCompass = serverName === COMPASS_SERVER_NAME
|
||||
|
||||
for (const toolName of server.toolNames) {
|
||||
const publicName = isCompass
|
||||
? toolName
|
||||
: `${serverName}__${toolName}`
|
||||
|
||||
toolIndex.set(publicName, {
|
||||
serverName,
|
||||
originalName: toolName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async connect(
|
||||
configs: readonly McpServerConfig[]
|
||||
): Promise<void> {
|
||||
for (const config of configs) {
|
||||
if (!config.enabled) continue
|
||||
|
||||
try {
|
||||
await connectServer(config)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to connect MCP server "${config.name}":`,
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildToolIndex()
|
||||
|
||||
// Fetch full tool metadata after index is built
|
||||
for (const [serverName, server] of servers) {
|
||||
const isCompass = serverName === COMPASS_SERVER_NAME
|
||||
|
||||
try {
|
||||
const result = await server.client.listTools()
|
||||
for (const tool of result.tools) {
|
||||
const publicName = isCompass
|
||||
? tool.name
|
||||
: `${serverName}__${tool.name}`
|
||||
|
||||
cachedTools.push({
|
||||
name: publicName,
|
||||
description: tool.description ?? "",
|
||||
input_schema:
|
||||
(tool.inputSchema as Record<string, unknown>) ??
|
||||
{},
|
||||
serverName,
|
||||
originalName: tool.name,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to list tools from "${serverName}":`,
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
listTools(): Array<{
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, unknown>
|
||||
serverName: string
|
||||
}> {
|
||||
return cachedTools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input_schema: t.input_schema,
|
||||
serverName: t.serverName,
|
||||
}))
|
||||
},
|
||||
|
||||
async callTool(
|
||||
name: string,
|
||||
input: unknown
|
||||
): Promise<string> {
|
||||
const entry = toolIndex.get(name)
|
||||
if (!entry) {
|
||||
return JSON.stringify({
|
||||
error: `Unknown MCP tool: ${name}`,
|
||||
})
|
||||
}
|
||||
|
||||
const server = servers.get(entry.serverName)
|
||||
if (!server) {
|
||||
return JSON.stringify({
|
||||
error: `MCP server "${entry.serverName}" not connected`,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await server.client.callTool({
|
||||
name: entry.originalName,
|
||||
arguments: input as Record<string, unknown>,
|
||||
})
|
||||
|
||||
// Extract text content from MCP result
|
||||
const contents = result.content
|
||||
if (!Array.isArray(contents) || contents.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const textParts: string[] = []
|
||||
for (const part of contents) {
|
||||
if (
|
||||
typeof part === "object" &&
|
||||
part !== null &&
|
||||
"type" in part &&
|
||||
part.type === "text" &&
|
||||
"text" in part &&
|
||||
typeof part.text === "string"
|
||||
) {
|
||||
textParts.push(part.text)
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join("")
|
||||
},
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
for (const [, server] of servers) {
|
||||
try {
|
||||
await server.client.close()
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
}
|
||||
servers.clear()
|
||||
toolIndex.clear()
|
||||
cachedTools = []
|
||||
},
|
||||
}
|
||||
}
|
||||
73
packages/agent-core/src/mcp/compass-server.ts
Normal file
73
packages/agent-core/src/mcp/compass-server.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
type ListToolsResult,
|
||||
type CallToolResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { createTools } from "../tools"
|
||||
import type { DataSource } from "../types"
|
||||
|
||||
// McpServer (high-level API) requires Zod schemas for tool registration.
|
||||
// Our tools use JSON Schema (Record<string, unknown>), so we use the
|
||||
// low-level Server with manual request handlers instead.
|
||||
|
||||
// Type predicates to convert our JSON Schema shape into the
|
||||
// MCP-expected inputSchema type without using `as` assertions.
|
||||
function isObjectRecord(val: unknown): val is Record<string, object> {
|
||||
return typeof val === "object" && val !== null && !Array.isArray(val)
|
||||
}
|
||||
|
||||
function isStringArray(val: unknown): val is string[] {
|
||||
return Array.isArray(val) && val.every((v) => typeof v === "string")
|
||||
}
|
||||
|
||||
function toInputSchema(
|
||||
raw: Record<string, unknown>,
|
||||
): { type: "object"; properties?: Record<string, object>; required?: string[] } {
|
||||
const props = raw["properties"]
|
||||
const reqs = raw["required"]
|
||||
return {
|
||||
type: "object",
|
||||
...(isObjectRecord(props) && { properties: props }),
|
||||
...(isStringArray(reqs) && { required: reqs }),
|
||||
}
|
||||
}
|
||||
|
||||
export function createCompassServer(dataSource: DataSource): Server {
|
||||
const server = new Server(
|
||||
{ name: "compass", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
const tools = createTools(dataSource)
|
||||
const toolMap = new Map(tools.map((t) => [t.name, t]))
|
||||
|
||||
server.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
(): ListToolsResult => ({
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: toInputSchema(t.input_schema),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (req): Promise<CallToolResult> => {
|
||||
const tool = toolMap.get(req.params.name)
|
||||
if (tool === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
const result = await tool.run(req.params.arguments ?? {})
|
||||
return { content: [{ type: "text", text: result }] }
|
||||
},
|
||||
)
|
||||
|
||||
return server
|
||||
}
|
||||
6
packages/agent-core/src/mcp/index.ts
Normal file
6
packages/agent-core/src/mcp/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { createCompassServer } from "./compass-server"
|
||||
export { createClientManager } from "./client-manager"
|
||||
export type {
|
||||
McpServerConfig,
|
||||
McpClientManager,
|
||||
} from "./types"
|
||||
32
packages/agent-core/src/mcp/types.ts
Normal file
32
packages/agent-core/src/mcp/types.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export interface McpServerConfig {
|
||||
readonly name: string
|
||||
readonly transport:
|
||||
| { readonly type: "in-memory" }
|
||||
| {
|
||||
readonly type: "stdio"
|
||||
readonly command: string
|
||||
readonly args?: readonly string[]
|
||||
readonly env?: Readonly<Record<string, string>>
|
||||
}
|
||||
| {
|
||||
readonly type: "http"
|
||||
readonly url: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
}
|
||||
readonly enabled: boolean
|
||||
}
|
||||
|
||||
export interface McpClientManager {
|
||||
connect(configs: readonly McpServerConfig[]): Promise<void>
|
||||
|
||||
listTools(): Array<{
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, unknown>
|
||||
serverName: string
|
||||
}>
|
||||
|
||||
callTool(name: string, input: unknown): Promise<string>
|
||||
|
||||
disconnect(): Promise<void>
|
||||
}
|
||||
118
packages/agent-core/src/oauth.ts
Normal file
118
packages/agent-core/src/oauth.ts
Normal file
@ -0,0 +1,118 @@
|
||||
// Anthropic OAuth constants (same as Claude Code / pi-ai)
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||
const SCOPES = "org:create_api_key user:profile user:inference"
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
readonly access_token: string
|
||||
readonly refresh_token: string
|
||||
readonly expires_in: number
|
||||
readonly token_type: string
|
||||
}
|
||||
|
||||
function base64url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ""
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte)
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
export async function generatePKCE(): Promise<{
|
||||
verifier: string
|
||||
challenge: string
|
||||
}> {
|
||||
// 32 random bytes -> 43 base64url chars, well within 43-128 range
|
||||
const verifierBytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
const verifier = base64url(verifierBytes.buffer)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||
const challenge = base64url(hash)
|
||||
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
export function buildAuthUrl(challenge: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
})
|
||||
return `${AUTHORIZE_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
function parseTokenResponse(raw: unknown): {
|
||||
access: string
|
||||
refresh: string
|
||||
expiresAt: number
|
||||
} {
|
||||
if (
|
||||
typeof raw !== "object" ||
|
||||
raw === null ||
|
||||
!("access_token" in raw) ||
|
||||
!("refresh_token" in raw) ||
|
||||
!("expires_in" in raw)
|
||||
) {
|
||||
throw new Error("Unexpected token response shape")
|
||||
}
|
||||
|
||||
const resp = raw as OAuthTokenResponse
|
||||
return {
|
||||
access: resp.access_token,
|
||||
refresh: resp.refresh_token,
|
||||
expiresAt: Date.now() + resp.expires_in * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
async function postToken(body: Record<string, string>): Promise<{
|
||||
access: string
|
||||
refresh: string
|
||||
expiresAt: number
|
||||
}> {
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`Token request failed (${res.status}): ${text}`)
|
||||
}
|
||||
|
||||
const json: unknown = await res.json()
|
||||
return parseTokenResponse(json)
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
state: string,
|
||||
verifier: string,
|
||||
): Promise<{ access: string; refresh: string; expiresAt: number }> {
|
||||
return postToken({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
})
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
): Promise<{ access: string; refresh: string; expiresAt: number }> {
|
||||
return postToken({
|
||||
grant_type: "refresh_token",
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
}
|
||||
34
packages/agent-core/src/stream-helpers.ts
Normal file
34
packages/agent-core/src/stream-helpers.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { SSEData } from "./types"
|
||||
|
||||
export function sseEncode(data: SSEData): string {
|
||||
return `data: ${JSON.stringify(data)}\n\n`
|
||||
}
|
||||
|
||||
export function createSSEStream(
|
||||
generator: AsyncGenerator<SSEData>
|
||||
): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const event of generator) {
|
||||
controller.enqueue(encoder.encode(sseEncode(event)))
|
||||
}
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
const errorEvent: SSEData = {
|
||||
type: "error",
|
||||
error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
controller.enqueue(
|
||||
encoder.encode(sseEncode(errorEvent))
|
||||
)
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
90
packages/agent-core/src/system-prompt.ts
Normal file
90
packages/agent-core/src/system-prompt.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import type { AgentContext } from "./types"
|
||||
|
||||
interface Message {
|
||||
readonly role: "user" | "assistant"
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
interface McpToolInfo {
|
||||
readonly serverName: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
interface SystemPromptOptions {
|
||||
readonly context: AgentContext
|
||||
readonly messages: readonly Message[]
|
||||
readonly externalMcpTools?: readonly McpToolInfo[]
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(
|
||||
contextOrOpts: AgentContext | SystemPromptOptions,
|
||||
messages?: readonly Message[]
|
||||
): string {
|
||||
// Support both old and new call signatures
|
||||
const opts: SystemPromptOptions =
|
||||
"context" in contextOrOpts
|
||||
? contextOrOpts
|
||||
: { context: contextOrOpts, messages: messages ?? [] }
|
||||
|
||||
const ctx = opts.context
|
||||
const history = buildConversationHistory(opts.messages)
|
||||
const externalTools = buildExternalToolsSection(
|
||||
opts.externalMcpTools
|
||||
)
|
||||
|
||||
return `You are Compass AI, an intelligent assistant for project \
|
||||
management and collaboration.
|
||||
|
||||
Current context:
|
||||
- User ID: ${ctx.userId}
|
||||
- Organization: ${ctx.orgId}
|
||||
- Role: ${ctx.role}
|
||||
- Current page: ${ctx.currentPage}
|
||||
- Timezone: ${ctx.timezone}
|
||||
|
||||
You have tools 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 \u2014 just call the tool and the action dispatches \
|
||||
automatically.${externalTools}${history}`
|
||||
}
|
||||
|
||||
function buildExternalToolsSection(
|
||||
tools?: readonly McpToolInfo[]
|
||||
): string {
|
||||
if (!tools || tools.length === 0) return ""
|
||||
|
||||
const byServer = new Map<string, string[]>()
|
||||
for (const tool of tools) {
|
||||
const list = byServer.get(tool.serverName) ?? []
|
||||
list.push(tool.name)
|
||||
byServer.set(tool.serverName, list)
|
||||
}
|
||||
|
||||
const sections: string[] = []
|
||||
for (const [server, names] of byServer) {
|
||||
sections.push(
|
||||
`- ${server}: ${names.join(", ")}`
|
||||
)
|
||||
}
|
||||
|
||||
return `\n\nExternal MCP servers connected:\n${sections.join("\n")}`
|
||||
}
|
||||
|
||||
function buildConversationHistory(
|
||||
messages: readonly Message[]
|
||||
): string {
|
||||
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.`
|
||||
}
|
||||
110
packages/agent-core/src/tools/dashboards.ts
Normal file
110
packages/agent-core/src/tools/dashboards.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { z } from "zod"
|
||||
import type { DataSource } from "../types"
|
||||
import type { ToolDef } from "./data"
|
||||
import { zodToJsonSchema } from "./data"
|
||||
|
||||
const saveSchema = z.object({
|
||||
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)"),
|
||||
})
|
||||
|
||||
const listSchema = z.object({})
|
||||
|
||||
const editSchema = z.object({
|
||||
dashboardId: z
|
||||
.string()
|
||||
.describe("ID of the dashboard to edit"),
|
||||
editPrompt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Description of changes to make"),
|
||||
})
|
||||
|
||||
const deleteSchema = z.object({
|
||||
dashboardId: z
|
||||
.string()
|
||||
.describe("ID of the dashboard to delete"),
|
||||
})
|
||||
|
||||
export function dashboardTools(
|
||||
dataSource: DataSource
|
||||
): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: "saveDashboard",
|
||||
description:
|
||||
"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.",
|
||||
input_schema: zodToJsonSchema(saveSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = saveSchema.parse(input)
|
||||
return JSON.stringify({
|
||||
action: "save_dashboard",
|
||||
name: args.name,
|
||||
description: args.description ?? "",
|
||||
dashboardId: args.dashboardId,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "listDashboards",
|
||||
description: "List the user's saved custom dashboards.",
|
||||
input_schema: zodToJsonSchema(listSchema),
|
||||
run: async (): Promise<string> => {
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/dashboards/list"
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "editDashboard",
|
||||
description:
|
||||
"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.",
|
||||
input_schema: zodToJsonSchema(editSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = editSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/dashboards/get",
|
||||
{ dashboardId: args.dashboardId }
|
||||
)
|
||||
return JSON.stringify({
|
||||
action: "load_dashboard",
|
||||
dashboardId: args.dashboardId,
|
||||
spec: result,
|
||||
editPrompt: args.editPrompt,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "deleteDashboard",
|
||||
description:
|
||||
"Delete a saved dashboard. Always confirm with the " +
|
||||
"user before deleting.",
|
||||
input_schema: zodToJsonSchema(deleteSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = deleteSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/dashboards/delete",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
164
packages/agent-core/src/tools/data.ts
Normal file
164
packages/agent-core/src/tools/data.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { z } from "zod"
|
||||
import type { DataSource } from "../types"
|
||||
|
||||
export interface ToolDef {
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
readonly input_schema: Record<string, unknown>
|
||||
readonly run: (input: unknown) => Promise<string>
|
||||
}
|
||||
|
||||
const queryDataSchema = z.object({
|
||||
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)"),
|
||||
})
|
||||
|
||||
type QueryDataInput = z.infer<typeof queryDataSchema>
|
||||
|
||||
export function dataTools(dataSource: DataSource): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: "queryData",
|
||||
description:
|
||||
"Query the application database. Describe what data " +
|
||||
"you need in natural language and provide a query type.",
|
||||
input_schema: zodToJsonSchema(queryDataSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = queryDataSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/query",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Minimal zod-to-JSON-schema converter for our tool schemas */
|
||||
export function zodToJsonSchema(
|
||||
schema: z.ZodObject<z.ZodRawShape>
|
||||
): Record<string, unknown> {
|
||||
const jsonSchema = zodObjectToProperties(schema)
|
||||
return {
|
||||
type: "object",
|
||||
properties: jsonSchema.properties,
|
||||
required: jsonSchema.required,
|
||||
}
|
||||
}
|
||||
|
||||
function zodObjectToProperties(schema: z.ZodObject<z.ZodRawShape>): {
|
||||
properties: Record<string, unknown>
|
||||
required: string[]
|
||||
} {
|
||||
const shape = schema.shape
|
||||
const properties: Record<string, unknown> = {}
|
||||
const required: string[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
const { schema: propSchema, isOptional } = unwrapOptional(
|
||||
value as z.ZodType
|
||||
)
|
||||
properties[key] = zodTypeToJsonSchema(propSchema)
|
||||
if (!isOptional) {
|
||||
required.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return { properties, required }
|
||||
}
|
||||
|
||||
function unwrapOptional(
|
||||
schema: z.ZodType
|
||||
): { schema: z.ZodType; isOptional: boolean } {
|
||||
if (schema instanceof z.ZodOptional) {
|
||||
return { schema: schema.unwrap(), isOptional: true }
|
||||
}
|
||||
if (schema instanceof z.ZodNullable) {
|
||||
return { schema: schema.unwrap(), isOptional: true }
|
||||
}
|
||||
return { schema, isOptional: false }
|
||||
}
|
||||
|
||||
function zodTypeToJsonSchema(
|
||||
schema: z.ZodType
|
||||
): Record<string, unknown> {
|
||||
const desc = schema.description
|
||||
|
||||
if (schema instanceof z.ZodString) {
|
||||
return { type: "string", ...(desc ? { description: desc } : {}) }
|
||||
}
|
||||
if (schema instanceof z.ZodNumber) {
|
||||
const result: Record<string, unknown> = {
|
||||
type: "number",
|
||||
...(desc ? { description: desc } : {}),
|
||||
}
|
||||
const checks = (schema as z.ZodNumber)._def.checks
|
||||
for (const check of checks) {
|
||||
if (check.kind === "min") result.minimum = check.value
|
||||
if (check.kind === "max") result.maximum = check.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (schema instanceof z.ZodBoolean) {
|
||||
return { type: "boolean", ...(desc ? { description: desc } : {}) }
|
||||
}
|
||||
if (schema instanceof z.ZodEnum) {
|
||||
return {
|
||||
type: "string",
|
||||
enum: schema.options,
|
||||
...(desc ? { description: desc } : {}),
|
||||
}
|
||||
}
|
||||
if (schema instanceof z.ZodArray) {
|
||||
return {
|
||||
type: "array",
|
||||
items: zodTypeToJsonSchema(schema.element),
|
||||
...(desc ? { description: desc } : {}),
|
||||
}
|
||||
}
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const inner = zodObjectToProperties(schema)
|
||||
return {
|
||||
type: "object",
|
||||
properties: inner.properties,
|
||||
required: inner.required,
|
||||
...(desc ? { description: desc } : {}),
|
||||
}
|
||||
}
|
||||
if (schema instanceof z.ZodRecord) {
|
||||
return {
|
||||
type: "object",
|
||||
additionalProperties: zodTypeToJsonSchema(schema.element),
|
||||
...(desc ? { description: desc } : {}),
|
||||
}
|
||||
}
|
||||
if (schema instanceof z.ZodNullable) {
|
||||
const inner = zodTypeToJsonSchema(schema.unwrap())
|
||||
return { ...inner, nullable: true }
|
||||
}
|
||||
if (schema instanceof z.ZodOptional) {
|
||||
return zodTypeToJsonSchema(schema.unwrap())
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return { ...(desc ? { description: desc } : {}) }
|
||||
}
|
||||
132
packages/agent-core/src/tools/github.ts
Normal file
132
packages/agent-core/src/tools/github.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { z } from "zod"
|
||||
import type { DataSource } from "../types"
|
||||
import type { ToolDef } from "./data"
|
||||
import { zodToJsonSchema } from "./data"
|
||||
|
||||
const querySchema = 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)"),
|
||||
})
|
||||
|
||||
const createIssueSchema = 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"),
|
||||
})
|
||||
|
||||
const interviewSchema = z.object({
|
||||
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"),
|
||||
})
|
||||
|
||||
export function githubTools(dataSource: DataSource): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: "queryGitHub",
|
||||
description:
|
||||
"Query GitHub repository data: commits, pull requests, " +
|
||||
"issues, contributors, milestones, or repo stats.",
|
||||
input_schema: zodToJsonSchema(querySchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = querySchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/github/query",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "createGitHubIssue",
|
||||
description:
|
||||
"Create a new GitHub issue in the Compass repository. " +
|
||||
"Always confirm with the user before creating.",
|
||||
input_schema: zodToJsonSchema(createIssueSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = createIssueSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/github/create-issue",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "saveInterviewFeedback",
|
||||
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.",
|
||||
input_schema: zodToJsonSchema(interviewSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = interviewSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/github/save-interview",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
26
packages/agent-core/src/tools/index.ts
Normal file
26
packages/agent-core/src/tools/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { DataSource } from "../types"
|
||||
import type { ToolDef } from "./data"
|
||||
import { dataTools } from "./data"
|
||||
import { uiTools } from "./ui"
|
||||
import { scheduleTools } from "./schedule"
|
||||
import { themeTools } from "./theme"
|
||||
import { memoryTools } from "./memory"
|
||||
import { skillTools } from "./skills"
|
||||
import { githubTools } from "./github"
|
||||
import { dashboardTools } from "./dashboards"
|
||||
|
||||
export type { ToolDef } from "./data"
|
||||
export { zodToJsonSchema } from "./data"
|
||||
|
||||
export function createTools(dataSource: DataSource): ToolDef[] {
|
||||
return [
|
||||
...dataTools(dataSource),
|
||||
...uiTools(),
|
||||
...scheduleTools(dataSource),
|
||||
...themeTools(dataSource),
|
||||
...memoryTools(dataSource),
|
||||
...skillTools(dataSource),
|
||||
...githubTools(dataSource),
|
||||
...dashboardTools(dataSource),
|
||||
]
|
||||
}
|
||||
74
packages/agent-core/src/tools/memory.ts
Normal file
74
packages/agent-core/src/tools/memory.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { z } from "zod"
|
||||
import type { DataSource } from "../types"
|
||||
import type { ToolDef } from "./data"
|
||||
import { zodToJsonSchema } from "./data"
|
||||
|
||||
const rememberSchema = z.object({
|
||||
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)"),
|
||||
})
|
||||
|
||||
const recallSchema = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe("What to search for in memories"),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Max results (default 5)"),
|
||||
})
|
||||
|
||||
export function memoryTools(dataSource: DataSource): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: "rememberContext",
|
||||
description:
|
||||
"Save something to persistent memory. Use when the " +
|
||||
"user shares a preference, makes a decision, or " +
|
||||
"mentions a fact worth remembering across sessions.",
|
||||
input_schema: zodToJsonSchema(rememberSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = rememberSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/memory/save",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "recallMemory",
|
||||
description:
|
||||
"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.",
|
||||
input_schema: zodToJsonSchema(recallSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = recallSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/memory/recall",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
270
packages/agent-core/src/tools/schedule.ts
Normal file
270
packages/agent-core/src/tools/schedule.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import { z } from "zod"
|
||||
import type { DataSource } from "../types"
|
||||
import type { ToolDef } from "./data"
|
||||
import { zodToJsonSchema } from "./data"
|
||||
|
||||
const getScheduleSchema = z.object({
|
||||
projectId: z.string().describe("The project UUID"),
|
||||
})
|
||||
|
||||
const createTaskSchema = z.object({
|
||||
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"),
|
||||
})
|
||||
|
||||
const updateTaskSchema = z.object({
|
||||
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)"),
|
||||
})
|
||||
|
||||
const deleteTaskSchema = z.object({
|
||||
taskId: z.string().describe("The task UUID to delete"),
|
||||
})
|
||||
|
||||
const createDepSchema = z.object({
|
||||
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)"),
|
||||
})
|
||||
|
||||
const deleteDepSchema = z.object({
|
||||
dependencyId: z
|
||||
.string()
|
||||
.describe("The dependency UUID to delete"),
|
||||
projectId: z
|
||||
.string()
|
||||
.describe("The project UUID (for revalidation)"),
|
||||
})
|
||||
|
||||
const addExceptionSchema = z.object({
|
||||
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"),
|
||||
})
|
||||
|
||||
const removeExceptionSchema = z.object({
|
||||
exceptionId: z
|
||||
.string()
|
||||
.describe("The exception UUID to remove"),
|
||||
projectId: z.string().describe("The project UUID"),
|
||||
})
|
||||
|
||||
export function scheduleTools(
|
||||
dataSource: DataSource
|
||||
): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: "getProjectSchedule",
|
||||
description:
|
||||
"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.",
|
||||
input_schema: zodToJsonSchema(getScheduleSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = getScheduleSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/schedule/get",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "createScheduleTask",
|
||||
description:
|
||||
"Create a new task on a project schedule. Returns a " +
|
||||
"toast confirmation. Dates are ISO format (YYYY-MM-DD).",
|
||||
input_schema: zodToJsonSchema(createTaskSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = createTaskSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/schedule/create",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "updateScheduleTask",
|
||||
description:
|
||||
"Update an existing schedule task. Provide only the " +
|
||||
"fields to change. Use getProjectSchedule first to " +
|
||||
"resolve task names to IDs.",
|
||||
input_schema: zodToJsonSchema(updateTaskSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = updateTaskSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/schedule/update",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "deleteScheduleTask",
|
||||
description:
|
||||
"Delete a schedule task. Always confirm with the user " +
|
||||
"before deleting. This also removes any dependencies " +
|
||||
"involving the task.",
|
||||
input_schema: zodToJsonSchema(deleteTaskSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = deleteTaskSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/schedule/delete",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "createScheduleDependency",
|
||||
description:
|
||||
"Create a dependency between two tasks. Has built-in " +
|
||||
"cycle detection. Use getProjectSchedule first to " +
|
||||
"resolve task names to IDs.",
|
||||
input_schema: zodToJsonSchema(createDepSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = createDepSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/schedule/create-dependency",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "deleteScheduleDependency",
|
||||
description:
|
||||
"Delete a dependency between tasks. Use " +
|
||||
"getProjectSchedule first to find the dependency ID.",
|
||||
input_schema: zodToJsonSchema(deleteDepSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = deleteDepSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/schedule/delete-dependency",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "addWorkdayException",
|
||||
description:
|
||||
"Add a workday exception to a project (holiday, " +
|
||||
"non-working day, or extra working day).",
|
||||
input_schema: zodToJsonSchema(addExceptionSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = addExceptionSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/schedule/add-exception",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "removeWorkdayException",
|
||||
description:
|
||||
"Remove a workday exception from a project.",
|
||||
input_schema: zodToJsonSchema(removeExceptionSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = removeExceptionSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/schedule/remove-exception",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
94
packages/agent-core/src/tools/skills.ts
Normal file
94
packages/agent-core/src/tools/skills.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { z } from "zod"
|
||||
import type { DataSource } from "../types"
|
||||
import type { ToolDef } from "./data"
|
||||
import { zodToJsonSchema } from "./data"
|
||||
|
||||
const installSchema = z.object({
|
||||
source: z
|
||||
.string()
|
||||
.describe(
|
||||
"GitHub source path, e.g. 'cloudflare/skills/wrangler'"
|
||||
),
|
||||
})
|
||||
|
||||
const listSchema = z.object({})
|
||||
|
||||
const toggleSchema = z.object({
|
||||
pluginId: z
|
||||
.string()
|
||||
.describe("The plugin ID of the skill"),
|
||||
enabled: z
|
||||
.boolean()
|
||||
.describe("true to enable, false to disable"),
|
||||
})
|
||||
|
||||
const uninstallSchema = z.object({
|
||||
pluginId: z
|
||||
.string()
|
||||
.describe("The plugin ID of the skill"),
|
||||
})
|
||||
|
||||
export function skillTools(dataSource: DataSource): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: "installSkill",
|
||||
description:
|
||||
"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.",
|
||||
input_schema: zodToJsonSchema(installSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = installSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/skills/install",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "listInstalledSkills",
|
||||
description:
|
||||
"List all installed agent skills with their status.",
|
||||
input_schema: zodToJsonSchema(listSchema),
|
||||
run: async (): Promise<string> => {
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/skills/list"
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "toggleInstalledSkill",
|
||||
description: "Enable or disable an installed skill.",
|
||||
input_schema: zodToJsonSchema(toggleSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = toggleSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/skills/toggle",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "uninstallSkill",
|
||||
description:
|
||||
"Remove an installed skill permanently. Requires " +
|
||||
"admin role. Always confirm before uninstalling.",
|
||||
input_schema: zodToJsonSchema(uninstallSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = uninstallSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/skills/uninstall",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
155
packages/agent-core/src/tools/theme.ts
Normal file
155
packages/agent-core/src/tools/theme.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { z } from "zod"
|
||||
import type { DataSource } from "../types"
|
||||
import type { ToolDef } from "./data"
|
||||
import { zodToJsonSchema } from "./data"
|
||||
|
||||
const listThemesSchema = z.object({})
|
||||
|
||||
const setThemeSchema = z.object({
|
||||
themeId: z.string().describe("The theme ID to activate"),
|
||||
})
|
||||
|
||||
const generateThemeSchema = z.object({
|
||||
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')"),
|
||||
})
|
||||
|
||||
const editThemeSchema = z.object({
|
||||
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"),
|
||||
})
|
||||
|
||||
export function themeTools(dataSource: DataSource): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: "listThemes",
|
||||
description:
|
||||
"List available visual themes (presets + user custom " +
|
||||
"themes).",
|
||||
input_schema: zodToJsonSchema(listThemesSchema),
|
||||
run: async (): Promise<string> => {
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/themes/list"
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "setTheme",
|
||||
description:
|
||||
"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.",
|
||||
input_schema: zodToJsonSchema(setThemeSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = setThemeSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/themes/set",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "generateTheme",
|
||||
description:
|
||||
"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.",
|
||||
input_schema: zodToJsonSchema(generateThemeSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = generateThemeSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/themes/generate",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "editTheme",
|
||||
description:
|
||||
"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).",
|
||||
input_schema: zodToJsonSchema(editThemeSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = editThemeSchema.parse(input)
|
||||
const result = await dataSource.fetch(
|
||||
"/api/compass/themes/edit",
|
||||
args
|
||||
)
|
||||
return JSON.stringify(result)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
144
packages/agent-core/src/tools/ui.ts
Normal file
144
packages/agent-core/src/tools/ui.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { z } from "zod"
|
||||
import type { ToolDef } from "./data"
|
||||
import { zodToJsonSchema } from "./data"
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
let p = path.trim()
|
||||
if (!p.startsWith("/dashboard")) {
|
||||
p = p.startsWith("/") ? `/dashboard${p}` : `/dashboard/${p}`
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
const navigateSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe(
|
||||
"Page path, e.g. 'projects' or '/dashboard/projects'"
|
||||
),
|
||||
reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Brief explanation of why navigating"),
|
||||
})
|
||||
|
||||
const notificationSchema = z.object({
|
||||
message: z.string().describe("The notification message"),
|
||||
type: z
|
||||
.enum(["default", "success", "error"])
|
||||
.optional()
|
||||
.describe("Notification style"),
|
||||
})
|
||||
|
||||
const generateUISchema = z.object({
|
||||
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."
|
||||
),
|
||||
})
|
||||
|
||||
type NavigateInput = z.infer<typeof navigateSchema>
|
||||
type NotificationInput = z.infer<typeof notificationSchema>
|
||||
type GenerateUIInput = z.infer<typeof generateUISchema>
|
||||
|
||||
export function uiTools(): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: "navigateTo",
|
||||
description:
|
||||
"Navigate the user to a page. Paths are relative to " +
|
||||
"/dashboard \u2014 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.",
|
||||
input_schema: zodToJsonSchema(navigateSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = navigateSchema.parse(input) as NavigateInput
|
||||
const resolved = normalizePath(args.path)
|
||||
if (!isValidRoute(resolved)) {
|
||||
return JSON.stringify({
|
||||
error:
|
||||
`"${args.path}" (resolved to "${resolved}") ` +
|
||||
"is not a valid page. Valid: projects, " +
|
||||
"projects/{id}, contacts, financials, people, " +
|
||||
"files, boards/{id}",
|
||||
})
|
||||
}
|
||||
return JSON.stringify({
|
||||
action: "navigate",
|
||||
path: resolved,
|
||||
reason: args.reason ?? null,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "showNotification",
|
||||
description:
|
||||
"Show a toast notification to the user. Use for " +
|
||||
"confirmations or important alerts.",
|
||||
input_schema: zodToJsonSchema(notificationSchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = notificationSchema.parse(
|
||||
input
|
||||
) as NotificationInput
|
||||
return JSON.stringify({
|
||||
action: "toast",
|
||||
message: args.message,
|
||||
type: args.type ?? "default",
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "generateUI",
|
||||
description:
|
||||
"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.",
|
||||
input_schema: zodToJsonSchema(generateUISchema),
|
||||
run: async (input: unknown): Promise<string> => {
|
||||
const args = generateUISchema.parse(
|
||||
input
|
||||
) as GenerateUIInput
|
||||
return JSON.stringify({
|
||||
action: "generateUI",
|
||||
renderPrompt: args.description,
|
||||
dataContext: args.dataContext ?? {},
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
46
packages/agent-core/src/types.ts
Normal file
46
packages/agent-core/src/types.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export interface ProviderConfig {
|
||||
readonly type: "anthropic" | "openrouter" | "ollama" | "custom"
|
||||
readonly apiKey?: string
|
||||
readonly baseUrl?: string
|
||||
readonly modelOverrides?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface AgentContext {
|
||||
readonly userId: string
|
||||
readonly orgId: string
|
||||
readonly role: string
|
||||
readonly isDemoUser: boolean
|
||||
readonly currentPage: string
|
||||
readonly timezone: string
|
||||
}
|
||||
|
||||
/** Abstracts HTTP calls vs direct DB access for tools */
|
||||
export interface DataSource {
|
||||
fetch(path: string, body?: unknown): Promise<unknown>
|
||||
}
|
||||
|
||||
/** SSE events emitted by the agentic loop */
|
||||
export type SSEData =
|
||||
| { 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: "result"
|
||||
readonly subtype: string
|
||||
readonly result: string
|
||||
readonly usage?: {
|
||||
readonly inputTokens: number
|
||||
readonly outputTokens: number
|
||||
readonly totalCostUsd: number
|
||||
}
|
||||
}
|
||||
| { readonly type: "error"; readonly error: string }
|
||||
14
packages/agent-core/tsconfig.json
Normal file
14
packages/agent-core/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
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
|
||||
40
packages/agent-server/bun.lock
Normal file
40
packages/agent-server/bun.lock
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "agent-server",
|
||||
"dependencies": {
|
||||
"agent-core": "file:../agent-core",
|
||||
"jose": "^5.9.6",
|
||||
"zod": "^3.24.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"agent-core": ["agent-core@file:../agent-core", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "zod": "^3.24.1" }, "devDependencies": { "bun-types": "^1.3.9" } }],
|
||||
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
22
packages/agent-server/package.json
Normal file
22
packages/agent-server/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "agent-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Standalone agent server using agent-core",
|
||||
"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": {
|
||||
"agent-core": "file:../agent-core",
|
||||
"jose": "^5.9.6",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
27
packages/agent-server/src/api-client.ts
Normal file
27
packages/agent-server/src/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>
|
||||
}
|
||||
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()
|
||||
151
packages/agent-server/src/index.ts
Normal file
151
packages/agent-server/src/index.ts
Normal file
@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Compass Agent Server
|
||||
* Standalone server using agent-core for the agentic loop
|
||||
*/
|
||||
|
||||
import { config } from "./config"
|
||||
import { validateAuth } from "./auth"
|
||||
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"
|
||||
|
||||
// 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(", ")}`)
|
||||
220
packages/agent-server/src/stream.ts
Normal file
220
packages/agent-server/src/stream.ts
Normal file
@ -0,0 +1,220 @@
|
||||
/**
|
||||
* SSE streaming using agent-core's agentic loop with MCP-based
|
||||
* tool routing. Supports both in-process compass tools and
|
||||
* external MCP servers (stdio + HTTP).
|
||||
*/
|
||||
|
||||
import {
|
||||
runAgent,
|
||||
buildSystemPrompt,
|
||||
createSSEStream,
|
||||
createCompassServer,
|
||||
createClientManager,
|
||||
} from "agent-core"
|
||||
import type {
|
||||
DataSource,
|
||||
ProviderConfig,
|
||||
McpServerConfig,
|
||||
} from "agent-core"
|
||||
import type {
|
||||
AuthContext,
|
||||
ProviderConfig as JWTProvider,
|
||||
} from "./auth"
|
||||
import { compassApi } from "./api-client"
|
||||
import { config } from "./config"
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ExternalServerConfig {
|
||||
readonly name: string
|
||||
readonly slug: string
|
||||
readonly transport: "stdio" | "http"
|
||||
readonly command?: string
|
||||
readonly args?: string
|
||||
readonly env?: string
|
||||
readonly url?: string
|
||||
readonly headers?: string
|
||||
}
|
||||
|
||||
interface StreamContext {
|
||||
auth: AuthContext
|
||||
authToken: string
|
||||
sessionId: string
|
||||
currentPage: string
|
||||
timezone: string
|
||||
provider?: JWTProvider
|
||||
model: string
|
||||
externalServers?: readonly ExternalServerConfig[]
|
||||
}
|
||||
|
||||
function mapProvider(context: StreamContext): ProviderConfig {
|
||||
if (!context.provider) {
|
||||
return {
|
||||
type: "anthropic",
|
||||
apiKey: config.anthropicApiKey,
|
||||
baseUrl: config.anthropicBaseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
switch (context.provider.type) {
|
||||
case "anthropic-key":
|
||||
case "anthropic-oauth":
|
||||
return {
|
||||
type: "anthropic",
|
||||
apiKey: context.provider.apiKey,
|
||||
baseUrl: context.provider.baseUrl,
|
||||
}
|
||||
case "openrouter":
|
||||
return {
|
||||
type: "openrouter",
|
||||
apiKey: context.provider.apiKey,
|
||||
}
|
||||
case "ollama":
|
||||
return {
|
||||
type: "ollama",
|
||||
baseUrl: context.provider.baseUrl,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
type: "custom",
|
||||
apiKey: context.provider.apiKey,
|
||||
baseUrl: context.provider.baseUrl,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildExternalConfigs(
|
||||
servers?: readonly ExternalServerConfig[]
|
||||
): McpServerConfig[] {
|
||||
if (!servers) return []
|
||||
|
||||
const configs: McpServerConfig[] = []
|
||||
for (const s of servers) {
|
||||
if (s.transport === "stdio" && s.command) {
|
||||
const args = s.args
|
||||
? (JSON.parse(s.args) as string[])
|
||||
: undefined
|
||||
const env = s.env
|
||||
? (JSON.parse(s.env) as Record<string, string>)
|
||||
: undefined
|
||||
configs.push({
|
||||
name: s.slug,
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: s.command,
|
||||
args,
|
||||
env,
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
} else if (s.transport === "http" && s.url) {
|
||||
const headers = s.headers
|
||||
? (JSON.parse(s.headers) as Record<string, string>)
|
||||
: undefined
|
||||
configs.push({
|
||||
name: s.slug,
|
||||
transport: {
|
||||
type: "http",
|
||||
url: s.url,
|
||||
headers,
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
export async function createAgentStream(
|
||||
messages: Message[],
|
||||
context: StreamContext
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
const provider = mapProvider(context)
|
||||
|
||||
const dataSource: DataSource = {
|
||||
async fetch(
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<unknown> {
|
||||
return compassApi(
|
||||
config.compassApiBaseUrl,
|
||||
path,
|
||||
context.authToken,
|
||||
body
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// Set up MCP-based tool routing
|
||||
const compassServer = createCompassServer(dataSource)
|
||||
const manager = createClientManager(compassServer)
|
||||
|
||||
const mcpConfigs: McpServerConfig[] = [
|
||||
{
|
||||
name: "compass",
|
||||
transport: { type: "in-memory" },
|
||||
enabled: true,
|
||||
},
|
||||
...buildExternalConfigs(context.externalServers),
|
||||
]
|
||||
|
||||
await manager.connect(mcpConfigs)
|
||||
|
||||
// Identify external tools for system prompt
|
||||
const allTools = manager.listTools()
|
||||
const externalMcpTools = allTools
|
||||
.filter((t) => t.serverName !== "compass")
|
||||
.map((t) => ({
|
||||
serverName: t.serverName,
|
||||
name: t.name,
|
||||
}))
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
context: {
|
||||
userId: context.auth.userId,
|
||||
orgId: context.auth.orgId,
|
||||
role: context.auth.role,
|
||||
isDemoUser: context.auth.isDemoUser,
|
||||
currentPage: context.currentPage,
|
||||
timezone: context.timezone,
|
||||
},
|
||||
messages,
|
||||
externalMcpTools:
|
||||
externalMcpTools.length > 0
|
||||
? externalMcpTools
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const isOAuth =
|
||||
provider.apiKey?.startsWith("sk-ant-oat") ?? false
|
||||
|
||||
const agentStream = runAgent({
|
||||
provider,
|
||||
model: context.model,
|
||||
systemPrompt,
|
||||
messages,
|
||||
mcpClientManager: manager,
|
||||
isOAuth,
|
||||
})
|
||||
|
||||
// Wrap to disconnect MCP after stream ends
|
||||
const sseStream = createSSEStream(agentStream)
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const reader = sseStream.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
controller.enqueue(value)
|
||||
}
|
||||
} finally {
|
||||
controller.close()
|
||||
await manager.disconnect()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
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 |
@ -7,6 +7,7 @@
|
||||
"core:app:default",
|
||||
"core:window:default",
|
||||
"core:webview:default",
|
||||
"core:webview:allow-set-webview-zoom",
|
||||
"shell:allow-open",
|
||||
"sql:default",
|
||||
"sql:allow-load",
|
||||
|
||||
39
src/app/(auth)/join/[code]/page.tsx
Normal file
39
src/app/(auth)/join/[code]/page.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getInviteByCode } from "@/app/actions/invites"
|
||||
import { JoinForm } from "@/components/auth/join-form"
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ code: string }>
|
||||
}
|
||||
|
||||
export default async function JoinPage({ params }: Props) {
|
||||
const { code } = await params
|
||||
|
||||
const result = await getInviteByCode(code)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Invalid invite
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This invite link is invalid or has expired.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentUser = await getCurrentUser()
|
||||
|
||||
return (
|
||||
<JoinForm
|
||||
code={code}
|
||||
orgName={result.data.organizationName}
|
||||
role={result.data.role}
|
||||
isAuthenticated={currentUser !== null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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({
|
||||
|
||||
238
src/app/actions/anthropic-oauth.ts
Normal file
238
src/app/actions/anthropic-oauth.ts
Normal file
@ -0,0 +1,238 @@
|
||||
"use server"
|
||||
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { getDb } from "@/db"
|
||||
import {
|
||||
anthropicOauthTokens,
|
||||
userProviderConfig,
|
||||
} from "@/db/schema-ai-config"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { encrypt, decrypt } from "@/lib/crypto"
|
||||
import { isDemoUser } from "@/lib/demo"
|
||||
import {
|
||||
exchangeCode as exchangeOAuthCode_,
|
||||
refreshAccessToken as refreshToken_,
|
||||
} from "agent-core"
|
||||
|
||||
export async function exchangeOAuthCode(
|
||||
code: string,
|
||||
state: string,
|
||||
verifier: string
|
||||
): Promise<{ success: true } | { success: false; 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 tokens = await exchangeOAuthCode_(code, state, verifier)
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
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)",
|
||||
}
|
||||
}
|
||||
|
||||
const encryptedAccess = await encrypt(
|
||||
tokens.access,
|
||||
encryptionKey,
|
||||
user.id
|
||||
)
|
||||
const encryptedRefresh = await encrypt(
|
||||
tokens.refresh,
|
||||
encryptionKey,
|
||||
user.id
|
||||
)
|
||||
|
||||
const db = getDb(env.DB)
|
||||
const now = new Date().toISOString()
|
||||
const expiresAt = new Date(tokens.expiresAt).toISOString()
|
||||
|
||||
await db
|
||||
.insert(anthropicOauthTokens)
|
||||
.values({
|
||||
userId: user.id,
|
||||
accessToken: encryptedAccess,
|
||||
refreshToken: encryptedRefresh,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: anthropicOauthTokens.userId,
|
||||
set: {
|
||||
accessToken: encryptedAccess,
|
||||
refreshToken: encryptedRefresh,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
|
||||
await db
|
||||
.insert(userProviderConfig)
|
||||
.values({
|
||||
userId: user.id,
|
||||
providerType: "anthropic-oauth",
|
||||
apiKey: null,
|
||||
baseUrl: null,
|
||||
modelOverrides: null,
|
||||
isActive: 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userProviderConfig.userId,
|
||||
set: {
|
||||
providerType: "anthropic-oauth",
|
||||
apiKey: null,
|
||||
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 exchange OAuth code",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a valid access token for the given userId, refreshing if needed.
|
||||
// Returns null if no token exists or on any error.
|
||||
export async function getOAuthAccessToken(
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(anthropicOauthTokens)
|
||||
.where(eq(anthropicOauthTokens.userId, userId))
|
||||
.get()
|
||||
|
||||
if (!row) return null
|
||||
|
||||
const encryptionKey = (
|
||||
env as unknown as Record<string, string>
|
||||
).PROVIDER_KEY_ENCRYPTION_KEY
|
||||
|
||||
if (!encryptionKey) return null
|
||||
|
||||
const isExpired =
|
||||
Date.now() >
|
||||
new Date(row.expiresAt).getTime() - 5 * 60 * 1000
|
||||
|
||||
if (!isExpired) {
|
||||
return await decrypt(row.accessToken, encryptionKey, userId)
|
||||
}
|
||||
|
||||
// Token expired — refresh
|
||||
const refreshToken = await decrypt(
|
||||
row.refreshToken,
|
||||
encryptionKey,
|
||||
userId
|
||||
)
|
||||
const fresh = await refreshToken_(refreshToken)
|
||||
|
||||
const encryptedAccess = await encrypt(
|
||||
fresh.access,
|
||||
encryptionKey,
|
||||
userId
|
||||
)
|
||||
const encryptedRefresh = await encrypt(
|
||||
fresh.refresh,
|
||||
encryptionKey,
|
||||
userId
|
||||
)
|
||||
const now = new Date().toISOString()
|
||||
const expiresAt = new Date(fresh.expiresAt).toISOString()
|
||||
|
||||
await db
|
||||
.update(anthropicOauthTokens)
|
||||
.set({
|
||||
accessToken: encryptedAccess,
|
||||
refreshToken: encryptedRefresh,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(anthropicOauthTokens.userId, userId))
|
||||
.run()
|
||||
|
||||
return fresh.access
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectOAuth(): Promise<{
|
||||
success: true
|
||||
}> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
// Still return success shape — nothing to disconnect
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
await db
|
||||
.delete(anthropicOauthTokens)
|
||||
.where(eq(anthropicOauthTokens.userId, user.id))
|
||||
.run()
|
||||
|
||||
await db
|
||||
.delete(userProviderConfig)
|
||||
.where(eq(userProviderConfig.userId, user.id))
|
||||
.run()
|
||||
|
||||
revalidatePath("/dashboard")
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function getOAuthStatus(): Promise<{
|
||||
connected: boolean
|
||||
expiresAt?: string
|
||||
}> {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { connected: false }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(anthropicOauthTokens)
|
||||
.where(eq(anthropicOauthTokens.userId, user.id))
|
||||
.get()
|
||||
|
||||
if (!row) return { connected: false }
|
||||
|
||||
return { connected: true, expiresAt: row.expiresAt }
|
||||
} catch {
|
||||
return { connected: false }
|
||||
}
|
||||
}
|
||||
@ -37,6 +37,7 @@ export async function getCustomDashboards(): Promise<
|
||||
if (!user) return { success: false, error: "not authenticated" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
if (!env?.DB) return { success: true, data: [] }
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const dashboards = await db.query.customDashboards.findMany({
|
||||
|
||||
359
src/app/actions/invites.ts
Normal file
359
src/app/actions/invites.ts
Normal file
@ -0,0 +1,359 @@
|
||||
"use server"
|
||||
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
import { getDb } from "@/db"
|
||||
import {
|
||||
organizationInvites,
|
||||
organizationMembers,
|
||||
organizations,
|
||||
users,
|
||||
type NewOrganizationInvite,
|
||||
} from "@/db/schema"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { requirePermission } from "@/lib/permissions"
|
||||
import { isDemoUser } from "@/lib/demo"
|
||||
import { eq, and, desc } from "drizzle-orm"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
// unambiguous charset — no 0/O/1/I/l
|
||||
const CODE_CHARS = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||
|
||||
function generateInviteCode(orgSlug: string): string {
|
||||
const prefix = orgSlug.replace(/[^a-z0-9]/g, "").slice(0, 3)
|
||||
const suffix = Array.from(
|
||||
{ length: 6 },
|
||||
() => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]
|
||||
).join("")
|
||||
return `${prefix}-${suffix}`
|
||||
}
|
||||
|
||||
// --- createInvite ---
|
||||
|
||||
export async function createInvite(
|
||||
role: string,
|
||||
maxUses?: number,
|
||||
expiresAt?: string
|
||||
): Promise<{ success: boolean; error?: string; data?: { code: string; url: string } }> {
|
||||
try {
|
||||
const currentUser = await getCurrentUser()
|
||||
if (!currentUser) return { success: false, error: "Unauthorized" }
|
||||
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
||||
requirePermission(currentUser, "organization", "create")
|
||||
|
||||
if (!currentUser.organizationId) {
|
||||
return { success: false, error: "No active organization" }
|
||||
}
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
if (!env?.DB) return { success: false, error: "Database not available" }
|
||||
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const org = await db
|
||||
.select({ slug: organizations.slug })
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, currentUser.organizationId))
|
||||
.get()
|
||||
|
||||
if (!org) return { success: false, error: "Organization not found" }
|
||||
|
||||
const code = generateInviteCode(org.slug)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const invite: NewOrganizationInvite = {
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: currentUser.organizationId,
|
||||
code,
|
||||
role,
|
||||
maxUses: maxUses ?? null,
|
||||
useCount: 0,
|
||||
expiresAt: expiresAt ?? null,
|
||||
createdBy: currentUser.id,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
await db.insert(organizationInvites).values(invite).run()
|
||||
|
||||
revalidatePath("/dashboard/settings")
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
code,
|
||||
url: `/join/${code}`,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating invite:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- getOrgInvites ---
|
||||
|
||||
export async function getOrgInvites(): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
data?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly code: string
|
||||
readonly role: string
|
||||
readonly maxUses: number | null
|
||||
readonly useCount: number
|
||||
readonly expiresAt: string | null
|
||||
readonly isActive: boolean
|
||||
readonly createdAt: string
|
||||
readonly createdByName: string | null
|
||||
}>
|
||||
}> {
|
||||
try {
|
||||
const currentUser = await getCurrentUser()
|
||||
if (!currentUser) return { success: false, error: "Unauthorized" }
|
||||
requirePermission(currentUser, "organization", "read")
|
||||
|
||||
if (!currentUser.organizationId) {
|
||||
return { success: false, error: "No active organization" }
|
||||
}
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
if (!env?.DB) return { success: false, error: "Database not available" }
|
||||
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const invites = await db
|
||||
.select({
|
||||
id: organizationInvites.id,
|
||||
code: organizationInvites.code,
|
||||
role: organizationInvites.role,
|
||||
maxUses: organizationInvites.maxUses,
|
||||
useCount: organizationInvites.useCount,
|
||||
expiresAt: organizationInvites.expiresAt,
|
||||
isActive: organizationInvites.isActive,
|
||||
createdAt: organizationInvites.createdAt,
|
||||
createdByName: users.displayName,
|
||||
})
|
||||
.from(organizationInvites)
|
||||
.leftJoin(users, eq(organizationInvites.createdBy, users.id))
|
||||
.where(eq(organizationInvites.organizationId, currentUser.organizationId))
|
||||
.orderBy(desc(organizationInvites.createdAt))
|
||||
|
||||
return { success: true, data: invites }
|
||||
} catch (error) {
|
||||
console.error("Error fetching org invites:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- revokeInvite ---
|
||||
|
||||
export async function revokeInvite(
|
||||
inviteId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const currentUser = await getCurrentUser()
|
||||
if (!currentUser) return { success: false, error: "Unauthorized" }
|
||||
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
||||
requirePermission(currentUser, "organization", "update")
|
||||
|
||||
if (!currentUser.organizationId) {
|
||||
return { success: false, error: "No active organization" }
|
||||
}
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
if (!env?.DB) return { success: false, error: "Database not available" }
|
||||
|
||||
const db = getDb(env.DB)
|
||||
|
||||
// verify invite belongs to this org before revoking
|
||||
const invite = await db
|
||||
.select({ id: organizationInvites.id })
|
||||
.from(organizationInvites)
|
||||
.where(
|
||||
and(
|
||||
eq(organizationInvites.id, inviteId),
|
||||
eq(organizationInvites.organizationId, currentUser.organizationId)
|
||||
)
|
||||
)
|
||||
.get()
|
||||
|
||||
if (!invite) return { success: false, error: "Invite not found" }
|
||||
|
||||
await db
|
||||
.update(organizationInvites)
|
||||
.set({ isActive: false })
|
||||
.where(eq(organizationInvites.id, inviteId))
|
||||
.run()
|
||||
|
||||
revalidatePath("/dashboard/settings")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Error revoking invite:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- getInviteByCode (public — no auth) ---
|
||||
|
||||
export async function getInviteByCode(code: string): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
data?: {
|
||||
readonly organizationName: string
|
||||
readonly role: string
|
||||
}
|
||||
}> {
|
||||
const INVALID = "This invite link is invalid or has expired"
|
||||
try {
|
||||
const { env } = await getCloudflareContext()
|
||||
if (!env?.DB) return { success: false, error: INVALID }
|
||||
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const row = await db
|
||||
.select({
|
||||
id: organizationInvites.id,
|
||||
role: organizationInvites.role,
|
||||
maxUses: organizationInvites.maxUses,
|
||||
useCount: organizationInvites.useCount,
|
||||
expiresAt: organizationInvites.expiresAt,
|
||||
isActive: organizationInvites.isActive,
|
||||
organizationName: organizations.name,
|
||||
})
|
||||
.from(organizationInvites)
|
||||
.innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id))
|
||||
.where(eq(organizationInvites.code, code))
|
||||
.get()
|
||||
|
||||
if (!row || !row.isActive) return { success: false, error: INVALID }
|
||||
if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
|
||||
return { success: false, error: INVALID }
|
||||
}
|
||||
if (row.maxUses !== null && row.useCount >= row.maxUses) {
|
||||
return { success: false, error: INVALID }
|
||||
}
|
||||
|
||||
return { success: true, data: { organizationName: row.organizationName, role: row.role } }
|
||||
} catch (error) {
|
||||
console.error("Error looking up invite:", error)
|
||||
return { success: false, error: INVALID }
|
||||
}
|
||||
}
|
||||
|
||||
// --- acceptInvite ---
|
||||
|
||||
export async function acceptInvite(code: string): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
data?: { organizationId: string; organizationName: string }
|
||||
}> {
|
||||
const INVALID = "This invite link is invalid or has expired"
|
||||
try {
|
||||
const currentUser = await getCurrentUser()
|
||||
if (!currentUser) return { success: false, error: "Unauthorized" }
|
||||
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
if (!env?.DB) return { success: false, error: "Database not available" }
|
||||
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const invite = await db
|
||||
.select({
|
||||
id: organizationInvites.id,
|
||||
organizationId: organizationInvites.organizationId,
|
||||
role: organizationInvites.role,
|
||||
maxUses: organizationInvites.maxUses,
|
||||
useCount: organizationInvites.useCount,
|
||||
expiresAt: organizationInvites.expiresAt,
|
||||
isActive: organizationInvites.isActive,
|
||||
organizationName: organizations.name,
|
||||
})
|
||||
.from(organizationInvites)
|
||||
.innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id))
|
||||
.where(eq(organizationInvites.code, code))
|
||||
.get()
|
||||
|
||||
if (!invite || !invite.isActive) return { success: false, error: INVALID }
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
|
||||
return { success: false, error: INVALID }
|
||||
}
|
||||
if (invite.maxUses !== null && invite.useCount >= invite.maxUses) {
|
||||
return { success: false, error: INVALID }
|
||||
}
|
||||
|
||||
// check user is not already a member
|
||||
const existing = await db
|
||||
.select({ id: organizationMembers.id })
|
||||
.from(organizationMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(organizationMembers.organizationId, invite.organizationId),
|
||||
eq(organizationMembers.userId, currentUser.id)
|
||||
)
|
||||
)
|
||||
.get()
|
||||
|
||||
if (existing) {
|
||||
return { success: false, error: "You are already a member of this organization" }
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db
|
||||
.insert(organizationMembers)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: invite.organizationId,
|
||||
userId: currentUser.id,
|
||||
role: invite.role,
|
||||
joinedAt: now,
|
||||
})
|
||||
.run()
|
||||
|
||||
const newUseCount = invite.useCount + 1
|
||||
const exhausted = invite.maxUses !== null && newUseCount >= invite.maxUses
|
||||
|
||||
await db
|
||||
.update(organizationInvites)
|
||||
.set({
|
||||
useCount: newUseCount,
|
||||
...(exhausted ? { isActive: false } : {}),
|
||||
})
|
||||
.where(eq(organizationInvites.id, invite.id))
|
||||
.run()
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set("compass-active-org", invite.organizationId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
})
|
||||
|
||||
revalidatePath("/dashboard")
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
organizationId: invite.organizationId,
|
||||
organizationName: invite.organizationName,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error accepting invite:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
192
src/app/actions/mcp-servers.ts
Normal file
192
src/app/actions/mcp-servers.ts
Normal file
@ -0,0 +1,192 @@
|
||||
"use server"
|
||||
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
import { getDb } from "@/db"
|
||||
import { mcpServers } from "@/db/schema-mcp"
|
||||
import type { McpServer } from "@/db/schema-mcp"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
type CreateMcpServerData = {
|
||||
readonly name: string
|
||||
readonly slug: string
|
||||
readonly transport: string
|
||||
readonly command?: string
|
||||
readonly args?: string
|
||||
readonly env?: string
|
||||
readonly url?: string
|
||||
readonly headers?: string
|
||||
}
|
||||
|
||||
type UpdateMcpServerData = Partial<CreateMcpServerData>
|
||||
|
||||
export async function getMcpServers(): Promise<
|
||||
| { success: true; data: ReadonlyArray<McpServer> }
|
||||
| { success: false; error: string }
|
||||
> {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "Unauthorized" }
|
||||
if (!user.organizationId) return { success: false, error: "No organization" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.orgId, user.organizationId))
|
||||
.all()
|
||||
|
||||
return { success: true, data: rows }
|
||||
} catch (error) {
|
||||
console.error("getMcpServers failed:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMcpServer(
|
||||
data: CreateMcpServerData
|
||||
): Promise<{ success: true; data: McpServer } | { success: false; error: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "Unauthorized" }
|
||||
if (!user.organizationId) return { success: false, error: "No organization" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const row: McpServer = {
|
||||
id: crypto.randomUUID(),
|
||||
orgId: user.organizationId,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
transport: data.transport,
|
||||
command: data.command ?? null,
|
||||
args: data.args ?? null,
|
||||
env: data.env ?? null,
|
||||
url: data.url ?? null,
|
||||
headers: data.headers ?? null,
|
||||
isEnabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: user.id,
|
||||
}
|
||||
|
||||
await db.insert(mcpServers).values(row).run()
|
||||
revalidatePath("/dashboard/settings")
|
||||
|
||||
return { success: true, data: row }
|
||||
} catch (error) {
|
||||
console.error("createMcpServer failed:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMcpServer(
|
||||
id: string,
|
||||
data: UpdateMcpServerData
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "Unauthorized" }
|
||||
if (!user.organizationId) return { success: false, error: "No organization" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const existing = await db
|
||||
.select({ id: mcpServers.id })
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.id, id), eq(mcpServers.orgId, user.organizationId)))
|
||||
.get()
|
||||
|
||||
if (!existing) return { success: false, error: "Server not found" }
|
||||
|
||||
await db.update(mcpServers).set(data).where(eq(mcpServers.id, id)).run()
|
||||
revalidatePath("/dashboard/settings")
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("updateMcpServer failed:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMcpServer(
|
||||
id: string
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "Unauthorized" }
|
||||
if (!user.organizationId) return { success: false, error: "No organization" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const existing = await db
|
||||
.select({ id: mcpServers.id })
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.id, id), eq(mcpServers.orgId, user.organizationId)))
|
||||
.get()
|
||||
|
||||
if (!existing) return { success: false, error: "Server not found" }
|
||||
|
||||
await db.delete(mcpServers).where(eq(mcpServers.id, id)).run()
|
||||
revalidatePath("/dashboard/settings")
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("deleteMcpServer failed:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMcpServer(
|
||||
id: string,
|
||||
enabled: boolean
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "Unauthorized" }
|
||||
if (!user.organizationId) return { success: false, error: "No organization" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const existing = await db
|
||||
.select({ id: mcpServers.id })
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.id, id), eq(mcpServers.orgId, user.organizationId)))
|
||||
.get()
|
||||
|
||||
if (!existing) return { success: false, error: "Server not found" }
|
||||
|
||||
await db
|
||||
.update(mcpServers)
|
||||
.set({ isEnabled: enabled })
|
||||
.where(eq(mcpServers.id, id))
|
||||
.run()
|
||||
revalidatePath("/dashboard/settings")
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("toggleMcpServer failed:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
435
src/app/actions/provider-config.ts
Normal file
435
src/app/actions/provider-config.ts
Normal file
@ -0,0 +1,435 @@
|
||||
"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
|
||||
|
||||
let decryptedApiKey: string | null = null
|
||||
if (config.apiKey) {
|
||||
if (!encryptionKey) {
|
||||
// Can't decrypt, but still return the config without a key
|
||||
decryptedApiKey = null
|
||||
} else {
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
let encryptedApiKey: string | null = null
|
||||
if (apiKey) {
|
||||
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)",
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
let encryptedApiKey: string | null = null
|
||||
if (apiKey) {
|
||||
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)",
|
||||
}
|
||||
}
|
||||
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,
|
||||
temperature: 0.7,
|
||||
// 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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
424
src/app/api/agent/route.ts
Executable file → Normal file
424
src/app/api/agent/route.ts
Executable file → Normal file
@ -1,42 +1,154 @@
|
||||
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"
|
||||
/**
|
||||
* Cloud-mode agent API route.
|
||||
* Runs on Cloudflare Workers via OpenNext. Uses agent-core
|
||||
* for the agentic loop with MCP-based tool routing.
|
||||
*/
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getProviderConfigForJwt } from "@/app/actions/provider-config"
|
||||
import { getOAuthAccessToken } from "@/app/actions/anthropic-oauth"
|
||||
import { generateAgentToken } from "@/lib/agent/api-auth"
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
import { getDb } from "@/db"
|
||||
import { mcpServers } from "@/db/schema-mcp"
|
||||
import { eq } from "drizzle-orm"
|
||||
import {
|
||||
runAgent,
|
||||
buildSystemPrompt,
|
||||
createSSEStream,
|
||||
createCompassServer,
|
||||
createClientManager,
|
||||
} from "agent-core"
|
||||
import type {
|
||||
DataSource,
|
||||
ProviderConfig,
|
||||
McpServerConfig,
|
||||
} from "agent-core"
|
||||
|
||||
interface ChatRequest {
|
||||
readonly messages: ReadonlyArray<{
|
||||
readonly role: "user" | "assistant"
|
||||
readonly content: string
|
||||
}>
|
||||
}
|
||||
|
||||
function mapProviderType(
|
||||
type: string
|
||||
): ProviderConfig["type"] {
|
||||
switch (type) {
|
||||
case "anthropic-key":
|
||||
case "anthropic-oauth":
|
||||
return "anthropic"
|
||||
case "openrouter":
|
||||
return "openrouter"
|
||||
case "ollama":
|
||||
return "ollama"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request
|
||||
): Promise<Response> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 })
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const { env, ctx } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
const envRecord = env as unknown as Record<string, string>
|
||||
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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const apiKey = envRecord.OPENROUTER_API_KEY
|
||||
if (!apiKey) {
|
||||
if (
|
||||
!Array.isArray(body.messages) ||
|
||||
body.messages.length === 0
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "OPENROUTER_API_KEY not configured",
|
||||
error:
|
||||
"messages array is required and cannot be empty",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const model =
|
||||
request.headers.get("x-model") ?? "sonnet"
|
||||
const currentPage =
|
||||
request.headers.get("x-current-page") ?? "/dashboard"
|
||||
const timezone =
|
||||
request.headers.get("x-timezone") ?? "UTC"
|
||||
|
||||
// Resolve provider config from DB
|
||||
let providerConfig = await getProviderConfigForJwt(
|
||||
user.id
|
||||
)
|
||||
if (!providerConfig) {
|
||||
providerConfig = await getProviderConfigForJwt(
|
||||
"org_default"
|
||||
)
|
||||
}
|
||||
|
||||
let provider: ProviderConfig = providerConfig
|
||||
? {
|
||||
type: mapProviderType(providerConfig.type),
|
||||
apiKey: providerConfig.apiKey ?? undefined,
|
||||
baseUrl: providerConfig.baseUrl ?? undefined,
|
||||
modelOverrides:
|
||||
providerConfig.modelOverrides ?? undefined,
|
||||
}
|
||||
: { type: "anthropic" }
|
||||
|
||||
// Resolve OAuth access token if needed
|
||||
if (providerConfig?.type === "anthropic-oauth") {
|
||||
const accessToken = await getOAuthAccessToken(user.id)
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Anthropic OAuth not connected or token expired",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
provider = {
|
||||
type: "anthropic",
|
||||
apiKey: accessToken,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT for bridge route auth
|
||||
const { env } = await getCloudflareContext()
|
||||
const envRecord = env as unknown as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
const agentSecret = envRecord.AGENT_AUTH_SECRET
|
||||
if (!agentSecret) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "AGENT_AUTH_SECRET not configured",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
@ -45,130 +157,166 @@ export async function POST(req: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
const { getCustomDashboards } = await import(
|
||||
"@/app/actions/dashboards"
|
||||
const token = await generateAgentToken(
|
||||
agentSecret,
|
||||
user.id,
|
||||
user.organizationId ?? "",
|
||||
user.role,
|
||||
false
|
||||
)
|
||||
|
||||
const [memories, registry, dashboardResult] =
|
||||
await Promise.all([
|
||||
loadMemoriesForPrompt(db, user.id),
|
||||
getRegistry(db, envRecord),
|
||||
getCustomDashboards(),
|
||||
])
|
||||
const baseUrl =
|
||||
envRecord.COMPASS_API_BASE_URL ??
|
||||
request.headers.get("origin") ??
|
||||
""
|
||||
|
||||
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 dataSource: DataSource = {
|
||||
async fetch(
|
||||
path: string,
|
||||
fetchBody?: unknown
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: fetchBody ? "POST" : "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: fetchBody
|
||||
? JSON.stringify(fetchBody)
|
||||
: undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res
|
||||
.json()
|
||||
.catch(() => ({ error: res.statusText }))
|
||||
const errObj = err as { error?: string }
|
||||
throw new Error(
|
||||
errObj.error ?? `API error ${res.status}`
|
||||
)
|
||||
}
|
||||
return res.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()
|
||||
// Set up MCP-based tool routing
|
||||
const compassServer = createCompassServer(dataSource)
|
||||
const manager = createClientManager(compassServer)
|
||||
|
||||
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
|
||||
// Load external MCP servers from DB (HTTP only on Workers)
|
||||
const mcpConfigs: McpServerConfig[] = [
|
||||
{
|
||||
name: "compass",
|
||||
transport: { type: "in-memory" },
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
if (user.organizationId) {
|
||||
try {
|
||||
const db = getDb(env.DB)
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.orgId, user.organizationId))
|
||||
.all()
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.isEnabled) continue
|
||||
// Workers can't spawn processes — skip stdio
|
||||
if (row.transport === "stdio") continue
|
||||
if (row.transport === "http" && row.url) {
|
||||
const headers = row.headers
|
||||
? (JSON.parse(row.headers) as Record<
|
||||
string,
|
||||
string
|
||||
>)
|
||||
: undefined
|
||||
mcpConfigs.push({
|
||||
name: row.slug,
|
||||
transport: {
|
||||
type: "http",
|
||||
url: row.url,
|
||||
headers,
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Failed to load external MCP servers:",
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const model = createModelFromId(apiKey, modelId)
|
||||
await manager.connect(mcpConfigs)
|
||||
|
||||
// detect demo mode
|
||||
const isDemo = isDemoUser(user.id)
|
||||
// Identify external tools for system prompt
|
||||
const allTools = manager.listTools()
|
||||
const externalMcpTools = allTools
|
||||
.filter((t) => t.serverName !== "compass")
|
||||
.map((t) => ({
|
||||
serverName: t.serverName,
|
||||
name: t.name,
|
||||
}))
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
system: buildSystemPrompt({
|
||||
userName: user.displayName ?? user.email,
|
||||
userRole: user.role,
|
||||
const msgs = body.messages as Array<{
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}>
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
context: {
|
||||
userId: user.id,
|
||||
orgId: user.organizationId ?? "",
|
||||
role: user.role,
|
||||
isDemoUser: false,
|
||||
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
|
||||
)
|
||||
messages: msgs,
|
||||
externalMcpTools:
|
||||
externalMcpTools.length > 0
|
||||
? externalMcpTools
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const isOAuth =
|
||||
provider.apiKey?.startsWith("sk-ant-oat") ?? false
|
||||
|
||||
const stream = runAgent({
|
||||
provider,
|
||||
model,
|
||||
systemPrompt,
|
||||
messages: msgs,
|
||||
mcpClientManager: manager,
|
||||
isOAuth,
|
||||
})
|
||||
|
||||
// Wrap stream to disconnect MCP after completion
|
||||
const sseStream = createSSEStream(stream)
|
||||
const wrappedStream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const reader = sseStream.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
controller.enqueue(value)
|
||||
}
|
||||
} finally {
|
||||
controller.close()
|
||||
await manager.disconnect()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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"
|
||||
return new Response(wrappedStream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getWorkOS } from "@workos-inc/authkit-nextjs"
|
||||
import { z } from "zod"
|
||||
import { ensureUserExists } from "@/lib/auth"
|
||||
|
||||
const verifyEmailSchema = z.object({
|
||||
code: z.string().min(1, "Verification code is required"),
|
||||
@ -51,6 +52,15 @@ export async function POST(request: NextRequest) {
|
||||
const workos = getWorkOS()
|
||||
await workos.userManagement.verifyEmail({ userId, code })
|
||||
|
||||
const user = await workos.userManagement.getUser(userId)
|
||||
await ensureUserExists({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Email verified successfully",
|
||||
|
||||
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" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -3,20 +3,13 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
IconAdjustments,
|
||||
IconBrain,
|
||||
IconPalette,
|
||||
IconPlug,
|
||||
IconPuzzle,
|
||||
IconRobot,
|
||||
IconTerminal2,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -24,14 +17,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { PreferencesTab } from "@/components/settings/preferences-tab"
|
||||
import { AppearanceTab } from "@/components/settings/appearance-tab"
|
||||
import { TeamTab } from "@/components/settings/team-tab"
|
||||
import { AIModelTab } from "@/components/settings/ai-model-tab"
|
||||
import { SkillsTab } from "@/components/settings/skills-tab"
|
||||
import { ClaudeCodeTab } from "@/components/settings/claude-code-tab"
|
||||
import { AgentTab } from "@/components/settings/agent-tab"
|
||||
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
|
||||
import { SyncControls } from "@/components/netsuite/sync-controls"
|
||||
import { GoogleDriveConnectionStatus } from "@/components/google/connection-status"
|
||||
@ -40,46 +32,17 @@ const SETTINGS_TABS = [
|
||||
{ value: "preferences", label: "Preferences", icon: IconAdjustments },
|
||||
{ value: "appearance", label: "Theme", icon: IconPalette },
|
||||
{ value: "team", label: "Team", icon: IconUsers },
|
||||
{ value: "ai-model", label: "AI Model", icon: IconBrain },
|
||||
{ value: "agent", label: "Agent", icon: IconRobot },
|
||||
{ value: "skills", label: "Skills", icon: IconPuzzle },
|
||||
{ value: "integrations", label: "Integrations", icon: IconPlug },
|
||||
{ value: "claude-code", label: "Code Bridge", icon: IconTerminal2 },
|
||||
] as const
|
||||
|
||||
type SectionValue = (typeof SETTINGS_TABS)[number]["value"]
|
||||
|
||||
// wide sections get unconstrained width for tables/complex layouts
|
||||
const WIDE_SECTIONS = new Set<string>([
|
||||
"appearance", "team", "ai-model", "claude-code",
|
||||
"appearance", "team", "agent",
|
||||
])
|
||||
|
||||
function AgentSection() {
|
||||
const [signetId, setSignetId] = React.useState("")
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signet-id" className="text-xs">
|
||||
Signet ID (ETH)
|
||||
</Label>
|
||||
<Input
|
||||
id="signet-id"
|
||||
value={signetId}
|
||||
onChange={(e) => setSignetId(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="h-9 max-w-sm font-mono"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<Button className="w-full max-w-sm">
|
||||
Configure your agent
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationsSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@ -106,16 +69,10 @@ export default function SettingsPage() {
|
||||
return <AppearanceTab />
|
||||
case "team":
|
||||
return <TeamTab />
|
||||
case "ai-model":
|
||||
return <AIModelTab />
|
||||
case "agent":
|
||||
return <AgentSection />
|
||||
case "skills":
|
||||
return <SkillsTab />
|
||||
return <AgentTab />
|
||||
case "integrations":
|
||||
return <IntegrationsSection />
|
||||
case "claude-code":
|
||||
return <ClaudeCodeTab />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Sora, IBM_Plex_Mono, Playfair_Display } from "next/font/google";
|
||||
import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AuthWrapper } from "@/components/auth-wrapper";
|
||||
import "./globals.css";
|
||||
|
||||
const sora = Sora({
|
||||
@ -44,11 +44,11 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${sora.variable} ${ibmPlexMono.variable} ${playfair.variable} font-sans antialiased`}>
|
||||
<AuthKitProvider>
|
||||
<AuthWrapper>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AuthKitProvider>
|
||||
</AuthWrapper>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -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,516 +1,280 @@
|
||||
"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,
|
||||
},
|
||||
})
|
||||
}
|
||||
const stored = loadState()
|
||||
if (
|
||||
stored.providerType !== state.providerType ||
|
||||
stored.model.id !== state.model.id
|
||||
) {
|
||||
state = stored
|
||||
emit()
|
||||
}
|
||||
}, [])
|
||||
|
||||
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
|
||||
// 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
|
||||
|
||||
let admin = false
|
||||
const d = result.data
|
||||
const providerType = (
|
||||
PROVIDER_TYPES.includes(
|
||||
d.providerType as ProviderType
|
||||
)
|
||||
? d.providerType
|
||||
: state.providerType
|
||||
) as ProviderType
|
||||
|
||||
if (configResult.success && configResult.data) {
|
||||
gModelId = configResult.data.modelId
|
||||
gModelName = configResult.data.modelName
|
||||
gProvider = configResult.data.provider
|
||||
canSelect =
|
||||
configResult.data.allowUserSelection
|
||||
ceiling =
|
||||
configResult.data.maxCostPerMillion
|
||||
admin = configResult.data.isAdmin
|
||||
}
|
||||
|
||||
const base: Partial<SharedState> = {
|
||||
global: {
|
||||
id: gModelId,
|
||||
name: gModelName,
|
||||
provider: gProvider,
|
||||
},
|
||||
allowUserSelection: canSelect,
|
||||
isAdmin: admin,
|
||||
maxCostPerMillion: ceiling,
|
||||
}
|
||||
|
||||
if (
|
||||
canSelect &&
|
||||
prefResult.success &&
|
||||
prefResult.data
|
||||
) {
|
||||
const prefValid =
|
||||
ceiling === null ||
|
||||
outputCostPerMillion(
|
||||
prefResult.data.completionCost
|
||||
) <= parseFloat(ceiling)
|
||||
|
||||
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: "",
|
||||
},
|
||||
if (providerType !== state.providerType) {
|
||||
updateState({ providerType })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShared({
|
||||
...base,
|
||||
display: {
|
||||
id: gModelId,
|
||||
name: gModelName,
|
||||
provider: gProvider,
|
||||
},
|
||||
.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
|
||||
)
|
||||
})
|
||||
.map((g) => ({
|
||||
...g,
|
||||
models: [...g.models].sort(
|
||||
(a, b) =>
|
||||
outputCostPerMillion(
|
||||
a.completionCost
|
||||
) -
|
||||
outputCostPerMillion(
|
||||
b.completionCost
|
||||
)
|
||||
),
|
||||
}))
|
||||
setGroups(sorted)
|
||||
}
|
||||
setListLoaded(true)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [open, listLoaded, bridgeActive])
|
||||
const usesModelPicker = providerUsesModelPicker(
|
||||
current.providerType
|
||||
)
|
||||
|
||||
// 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) {
|
||||
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",
|
||||
"hover:bg-muted hover:text-foreground transition-colors",
|
||||
"text-emerald-600 dark:text-emerald-400",
|
||||
open && "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="max-w-32 truncate">
|
||||
{state.bridgeModel.name}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-64 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
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleBridgeModelSelect(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"
|
||||
)}
|
||||
>
|
||||
<ProviderIcon
|
||||
provider="Anthropic"
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="text-xs font-medium flex-1">
|
||||
{model.name}
|
||||
</span>
|
||||
{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>
|
||||
)
|
||||
const handleCustomModelChange = (v: string): void => {
|
||||
updateState({ customModelId: v })
|
||||
}
|
||||
|
||||
return (
|
||||
@ -519,17 +283,15 @@ export function ModelDropdown(): React.JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground",
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs",
|
||||
"hover:bg-muted hover:text-foreground transition-colors",
|
||||
"text-muted-foreground",
|
||||
open && "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={state.display.provider}
|
||||
size={14}
|
||||
/>
|
||||
<span className="max-w-28 truncate">
|
||||
{state.display.name}
|
||||
<ClaudeSparkle size={14} />
|
||||
<span className="max-w-36 truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
@ -537,173 +299,57 @@ export function ModelDropdown(): React.JSX.Element {
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-96 p-0"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className="w-56 p-1"
|
||||
>
|
||||
<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
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
{usesModelPicker ? (
|
||||
<div role="radiogroup" aria-label="Model">
|
||||
{AGENT_MODELS.map((m) => {
|
||||
const isActive = m.id === current.model.id
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
onClick={() => handleModelSelect(m)}
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
) : (
|
||||
<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)
|
||||
}
|
||||
placeholder="llama3.2"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
}
|
||||
26
src/components/auth-wrapper.tsx
Normal file
26
src/components/auth-wrapper.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* Server component that conditionally renders the real AuthKitProvider
|
||||
* (when WorkOS is configured) or a simple passthrough (demo/local mode).
|
||||
*
|
||||
* This avoids importing @workos-inc/authkit-nextjs/components when
|
||||
* WORKOS_API_KEY is empty, which would throw NoApiKeyProvidedException.
|
||||
*/
|
||||
|
||||
const isWorkOSConfigured =
|
||||
!!process.env.WORKOS_API_KEY &&
|
||||
!!process.env.WORKOS_CLIENT_ID &&
|
||||
!process.env.WORKOS_API_KEY.includes("placeholder")
|
||||
|
||||
export async function AuthWrapper({ children }: { children: ReactNode }) {
|
||||
if (isWorkOSConfigured) {
|
||||
// Dynamic import so the module is never loaded when WorkOS is absent
|
||||
const { AuthKitProvider } = await import(
|
||||
"@workos-inc/authkit-nextjs/components"
|
||||
)
|
||||
return <AuthKitProvider>{children}</AuthKitProvider>
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
74
src/components/auth/join-form.tsx
Normal file
74
src/components/auth/join-form.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { IconLoader } from "@tabler/icons-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { acceptInvite } from "@/app/actions/invites"
|
||||
|
||||
interface Props {
|
||||
code: string
|
||||
orgName: string
|
||||
role: string
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function JoinForm({ code, orgName, role, isAuthenticated }: Props) {
|
||||
const router = useRouter()
|
||||
const [isPending, setIsPending] = React.useState(false)
|
||||
|
||||
async function handleJoin() {
|
||||
setIsPending(true)
|
||||
try {
|
||||
const result = await acceptInvite(code)
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Failed to join organization")
|
||||
return
|
||||
}
|
||||
router.push("/dashboard")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSignIn() {
|
||||
router.push(`/login?from=/join/${code}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
You've been invited
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Join <span className="font-medium text-foreground">{orgName}</span> as{" "}
|
||||
<span className="font-medium text-foreground capitalize">{role}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleJoin}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && (
|
||||
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Join {orgName}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full" onClick={handleSignIn}>
|
||||
Sign in to join
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
You'll be redirected back after signing in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -45,11 +45,14 @@ export function SignupForm() {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
userId?: string
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message || "Account created!")
|
||||
router.push("/verify-email?email=" + encodeURIComponent(data.email))
|
||||
router.push(
|
||||
`/verify-email?email=${encodeURIComponent(data.email)}&userId=${encodeURIComponent(result.userId ?? "")}`
|
||||
)
|
||||
} else {
|
||||
toast.error(result.error || "Signup failed")
|
||||
}
|
||||
|
||||
@ -116,7 +116,21 @@ export function DesktopShell({ children }: DesktopShellProps) {
|
||||
const { registerShortcuts } = await import(
|
||||
"@/lib/desktop/shortcuts"
|
||||
)
|
||||
unregister = await registerShortcuts({ triggerSync })
|
||||
const { WindowManager } = await import("@/lib/desktop/window-manager")
|
||||
unregister = await registerShortcuts({
|
||||
triggerSync,
|
||||
onZoomIn: () => {
|
||||
const current = WindowManager.getZoom()
|
||||
WindowManager.setZoom(Math.round((current + 0.1) * 10) / 10)
|
||||
},
|
||||
onZoomOut: () => {
|
||||
const current = WindowManager.getZoom()
|
||||
WindowManager.setZoom(Math.round((current - 0.1) * 10) / 10)
|
||||
},
|
||||
onZoomReset: () => {
|
||||
WindowManager.setZoom(1.0)
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to register desktop shortcuts:", error)
|
||||
}
|
||||
|
||||
@ -29,10 +29,8 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
|
||||
import { SyncControls } from "@/components/netsuite/sync-controls"
|
||||
import { GoogleDriveConnectionStatus } from "@/components/google/connection-status"
|
||||
import { SkillsTab } from "@/components/settings/skills-tab"
|
||||
import { AIModelTab } from "@/components/settings/ai-model-tab"
|
||||
import { AppearanceTab } from "@/components/settings/appearance-tab"
|
||||
import { ClaudeCodeTab } from "@/components/settings/claude-code-tab"
|
||||
import { AgentTab } from "@/components/settings/agent-tab"
|
||||
import { TeamTab } from "@/components/settings/team-tab"
|
||||
import { useNative } from "@/hooks/use-native"
|
||||
import { useBiometricAuth } from "@/hooks/use-biometric-auth"
|
||||
@ -44,9 +42,7 @@ const SETTINGS_TABS = [
|
||||
{ value: "notifications", label: "Notifications" },
|
||||
{ value: "appearance", label: "Theme" },
|
||||
{ value: "integrations", label: "Integrations" },
|
||||
{ value: "ai-model", label: "AI Model" },
|
||||
{ value: "agent", label: "Agent" },
|
||||
{ value: "skills", label: "Skills" },
|
||||
] as const
|
||||
|
||||
const CREATE_SETTING_TAB = {
|
||||
@ -91,7 +87,6 @@ export function SettingsModal({
|
||||
const [pushNotifs, setPushNotifs] = React.useState(true)
|
||||
const [weeklyDigest, setWeeklyDigest] = React.useState(false)
|
||||
const [timezone, setTimezone] = React.useState("America/New_York")
|
||||
const [signetId, setSignetId] = React.useState("")
|
||||
const [customTabs, setCustomTabs] = React.useState<ReadonlyArray<CustomSettingTab>>([])
|
||||
const [activeTab, setActiveTab] = React.useState<string>("general")
|
||||
const [newSettingName, setNewSettingName] = React.useState("")
|
||||
@ -263,41 +258,11 @@ export function SettingsModal({
|
||||
<Separator />
|
||||
<NetSuiteConnectionStatus />
|
||||
<SyncControls />
|
||||
<Separator />
|
||||
<ClaudeCodeTab />
|
||||
</div>
|
||||
)
|
||||
|
||||
case "ai-model":
|
||||
return <div className="pt-2"><AIModelTab /></div>
|
||||
|
||||
case "agent":
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signet-id" className="text-xs">
|
||||
Signet ID (ETH)
|
||||
</Label>
|
||||
<Input
|
||||
id="signet-id"
|
||||
value={signetId}
|
||||
onChange={(e) => setSignetId(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="h-9 font-mono"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button className="w-full">
|
||||
Configure your agent
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "skills":
|
||||
return <div className="pt-2"><SkillsTab /></div>
|
||||
return <div className="flex min-h-0 flex-1 pt-2"><AgentTab /></div>
|
||||
|
||||
case CREATE_SETTING_TAB.value:
|
||||
return (
|
||||
|
||||
96
src/components/settings/agent-tab.tsx
Normal file
96
src/components/settings/agent-tab.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
BrainCircuit,
|
||||
Blocks,
|
||||
Cable,
|
||||
Fingerprint,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { AIModelTab } from "@/components/settings/ai-model-tab"
|
||||
import { SkillsTab } from "@/components/settings/skills-tab"
|
||||
import { ClaudeCodeTab } from "@/components/settings/claude-code-tab"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SUB_TABS = [
|
||||
{ value: "model", label: "Model", icon: BrainCircuit },
|
||||
{ value: "skills", label: "Skills", icon: Blocks },
|
||||
{ value: "bridge", label: "Bridge", icon: Cable },
|
||||
{ value: "identity", label: "Identity", icon: Fingerprint },
|
||||
] as const
|
||||
|
||||
type SubTab = (typeof SUB_TABS)[number]["value"]
|
||||
|
||||
export function AgentTab(): React.ReactElement {
|
||||
const [activeSubTab, setActiveSubTab] =
|
||||
React.useState<SubTab>("model")
|
||||
const [signetId, setSignetId] = React.useState("")
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div
|
||||
className="flex gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label="Agent settings"
|
||||
>
|
||||
{SUB_TABS.map((tab) => {
|
||||
const isActive = activeSubTab === tab.value
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
onClick={() => setActiveSubTab(tab.value)}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{activeSubTab === "model" && <AIModelTab />}
|
||||
{activeSubTab === "skills" && <SkillsTab />}
|
||||
{activeSubTab === "bridge" && <ClaudeCodeTab />}
|
||||
{activeSubTab === "identity" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signet-id" className="text-xs">
|
||||
Signet ID (ETH)
|
||||
</Label>
|
||||
<Input
|
||||
id="signet-id"
|
||||
value={signetId}
|
||||
onChange={(e) => setSignetId(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="h-9 font-mono"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button className="w-full">
|
||||
Configure your agent
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -3,8 +3,12 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Search,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Bar,
|
||||
@ -39,6 +43,20 @@ import {
|
||||
getUsageMetrics,
|
||||
updateModelPolicy,
|
||||
} from "@/app/actions/ai-config"
|
||||
import {
|
||||
getUserProviderConfig,
|
||||
setUserProviderConfig,
|
||||
clearUserProviderConfig,
|
||||
} from "@/app/actions/provider-config"
|
||||
import {
|
||||
exchangeOAuthCode,
|
||||
disconnectOAuth,
|
||||
getOAuthStatus,
|
||||
} from "@/app/actions/anthropic-oauth"
|
||||
import {
|
||||
generatePKCE,
|
||||
buildAuthUrl,
|
||||
} from "@/lib/anthropic-oauth-client"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -46,6 +64,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 +116,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 +230,437 @@ function outputCostPerMillion(
|
||||
return parseFloat(completionCost) * 1_000_000
|
||||
}
|
||||
|
||||
// --- two-panel model picker ---
|
||||
// ============================================================================
|
||||
// Provider Configuration Section
|
||||
// ============================================================================
|
||||
|
||||
type OAuthState =
|
||||
| { step: "idle" }
|
||||
| { step: "connecting"; verifier: string }
|
||||
| { step: "connected"; expiresAt?: string }
|
||||
|
||||
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)
|
||||
|
||||
// OAuth state
|
||||
const [oauth, setOAuth] = React.useState<OAuthState>({
|
||||
step: "idle",
|
||||
})
|
||||
const [oauthCode, setOAuthCode] = React.useState("")
|
||||
|
||||
// load current config + OAuth status from D1
|
||||
React.useEffect(() => {
|
||||
Promise.all([
|
||||
getUserProviderConfig(),
|
||||
getOAuthStatus(),
|
||||
])
|
||||
.then(([configResult, oauthStatus]) => {
|
||||
if (
|
||||
"success" in configResult &&
|
||||
configResult.success &&
|
||||
configResult.data
|
||||
) {
|
||||
const d = configResult.data
|
||||
const type = (
|
||||
PROVIDER_TYPES.includes(
|
||||
d.providerType as ProviderType
|
||||
)
|
||||
? d.providerType
|
||||
: "anthropic-oauth"
|
||||
) as ProviderType
|
||||
setActiveType(type)
|
||||
setBaseUrl(d.baseUrl ?? "")
|
||||
setHasStoredKey(d.hasApiKey)
|
||||
}
|
||||
if (oauthStatus.connected) {
|
||||
setOAuth({
|
||||
step: "connected",
|
||||
expiresAt: oauthStatus.expiresAt,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const info = PROVIDERS.find(
|
||||
(p) => p.type === activeType
|
||||
) ?? PROVIDERS[0]
|
||||
|
||||
const handleProviderSelect = (
|
||||
type: ProviderType
|
||||
): void => {
|
||||
setActiveType(type)
|
||||
setApiKey("")
|
||||
setShowKey(false)
|
||||
setOAuthCode("")
|
||||
if (type !== "anthropic-oauth") {
|
||||
setOAuth({ step: "idle" })
|
||||
}
|
||||
const newInfo = PROVIDERS.find(
|
||||
(p) => p.type === type
|
||||
)
|
||||
setBaseUrl(newInfo?.defaultBaseUrl ?? "")
|
||||
setHasStoredKey(false)
|
||||
}
|
||||
|
||||
const handleOAuthConnect = async (): Promise<void> => {
|
||||
// Open window immediately in the click handler to avoid
|
||||
// popup blockers (async gap kills the user gesture).
|
||||
// Can't use noopener here — it makes window.open return null.
|
||||
const popup = window.open("about:blank", "_blank")
|
||||
const { verifier, challenge } = await generatePKCE()
|
||||
const url = buildAuthUrl(challenge)
|
||||
if (popup) {
|
||||
popup.opener = null
|
||||
popup.location.href = url
|
||||
}
|
||||
setOAuth({ step: "connecting", verifier })
|
||||
}
|
||||
|
||||
const handleOAuthSubmit = async (): Promise<void> => {
|
||||
if (oauth.step !== "connecting") return
|
||||
const trimmed = oauthCode.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
// Parse "code#state" or just "code"
|
||||
const hashIdx = trimmed.indexOf("#")
|
||||
const code =
|
||||
hashIdx >= 0 ? trimmed.slice(0, hashIdx) : trimmed
|
||||
const state =
|
||||
hashIdx >= 0 ? trimmed.slice(hashIdx + 1) : ""
|
||||
|
||||
setSaving(true)
|
||||
const result = await exchangeOAuthCode(
|
||||
code,
|
||||
state,
|
||||
oauth.verifier
|
||||
)
|
||||
setSaving(false)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Connected to Anthropic")
|
||||
setOAuth({ step: "connected" })
|
||||
setOAuthCode("")
|
||||
setProviderType("anthropic-oauth")
|
||||
onProviderChanged()
|
||||
} else {
|
||||
toast.error(result.error ?? "OAuth failed")
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuthDisconnect =
|
||||
async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
await disconnectOAuth()
|
||||
setSaving(false)
|
||||
setOAuth({ step: "idle" })
|
||||
toast.success("Disconnected")
|
||||
onProviderChanged()
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* OAuth flow for anthropic-oauth */}
|
||||
{activeType === "anthropic-oauth" && (
|
||||
<div className="space-y-2">
|
||||
{oauth.step === "connected" && (
|
||||
<div className="flex items-center justify-between rounded-md border border-green-500/20 bg-green-500/5 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
Connected
|
||||
</span>
|
||||
{oauth.expiresAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
expires{" "}
|
||||
{new Date(
|
||||
oauth.expiresAt
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
onClick={handleOAuthDisconnect}
|
||||
disabled={saving}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauth.step === "idle" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleOAuthConnect}
|
||||
>
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
Connect with Anthropic
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{oauth.step === "connecting" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Authorize in the browser tab that opened,
|
||||
then paste the code below.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={oauthCode}
|
||||
onChange={(e) =>
|
||||
setOAuthCode(e.target.value)
|
||||
}
|
||||
placeholder="Paste authorization code here"
|
||||
className="h-8 text-xs font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={handleOAuthSubmit}
|
||||
disabled={saving || !oauthCode.trim()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => {
|
||||
setOAuth({ step: "idle" })
|
||||
setOAuthCode("")
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* credential inputs (non-OAuth providers) */}
|
||||
{activeType !== "anthropic-oauth" &&
|
||||
(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 (non-OAuth providers) */}
|
||||
{activeType !== "anthropic-oauth" && (
|
||||
<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>
|
||||
<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 +685,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 +694,6 @@ function ModelPicker({
|
||||
})
|
||||
}, [groups])
|
||||
|
||||
// filter models by search + active provider + cost ceiling
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
return sortedGroups
|
||||
.map((group) => {
|
||||
@ -240,7 +764,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 +774,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 +836,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 +900,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 +926,9 @@ function ModelPicker({
|
||||
)
|
||||
}
|
||||
|
||||
// --- usage metrics ---
|
||||
// ============================================================================
|
||||
// Usage section
|
||||
// ============================================================================
|
||||
|
||||
const chartConfig = {
|
||||
tokens: {
|
||||
@ -527,7 +1048,9 @@ function UsageSection({
|
||||
)
|
||||
}
|
||||
|
||||
// --- main tab ---
|
||||
// ============================================================================
|
||||
// Main tab
|
||||
// ============================================================================
|
||||
|
||||
export function AIModelTab() {
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
@ -631,6 +1154,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 ? (
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Check, Moon, Sparkles, Sun, Trash2 } from "lucide-react"
|
||||
import { Check, Moon, RotateCcw, Sparkles, Sun, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCompassTheme } from "@/components/theme-provider"
|
||||
import { useAgentOptional } from "@/components/agent/chat-provider"
|
||||
@ -172,6 +173,59 @@ export function AppearanceTab() {
|
||||
|
||||
const isDark = resolvedTheme === "dark"
|
||||
|
||||
const [zoomLevel, setZoomLevel] = React.useState(1.0)
|
||||
|
||||
// Load persisted zoom level on mount
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("compass-zoom-level")
|
||||
if (stored) {
|
||||
const level = parseFloat(stored)
|
||||
if (!isNaN(level) && level >= 0.5 && level <= 2.0) {
|
||||
setZoomLevel(level)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function applyZoom(level: number): Promise<void> {
|
||||
const clamped = Math.min(2.0, Math.max(0.5, level))
|
||||
try {
|
||||
localStorage.setItem("compass-zoom-level", String(clamped))
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
// Use Tauri native webview zoom (true browser-level zoom)
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core")
|
||||
await invoke("plugin:webview|set_webview_zoom", {
|
||||
label: "main",
|
||||
scaleFactor: clamped,
|
||||
})
|
||||
// Clear any CSS fallback
|
||||
document.documentElement.style.fontSize = ""
|
||||
return
|
||||
} catch {
|
||||
// Not in Tauri or permission denied — CSS fallback
|
||||
}
|
||||
// Fallback: scale root font-size (slightly thicker icons but functional)
|
||||
document.documentElement.style.fontSize = `${clamped * 16}px`
|
||||
}
|
||||
|
||||
function handleZoomChange(value: number[]): void {
|
||||
const level = value[0]
|
||||
if (level === undefined) return
|
||||
setZoomLevel(level)
|
||||
void applyZoom(level)
|
||||
}
|
||||
|
||||
function handleZoomReset(): void {
|
||||
setZoomLevel(1.0)
|
||||
void applyZoom(1.0)
|
||||
}
|
||||
|
||||
const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>(
|
||||
() => [...THEME_PRESETS, ...customThemes],
|
||||
[customThemes],
|
||||
@ -246,6 +300,40 @@ export function AppearanceTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ui scale */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">UI Scale</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{Math.round(zoomLevel * 100)}%
|
||||
</span>
|
||||
{zoomLevel !== 1.0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomReset}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[zoomLevel]}
|
||||
onValueChange={handleZoomChange}
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>50%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* theme grid */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Theme</p>
|
||||
|
||||
192
src/components/settings/create-invite-dialog.tsx
Normal file
192
src/components/settings/create-invite-dialog.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconCopy } from "@tabler/icons-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { createInvite } from "@/app/actions/invites"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
const EXPIRY_PRESETS = [
|
||||
{ label: "Never", value: "never" },
|
||||
{ label: "1 hour", value: "1h" },
|
||||
{ label: "1 day", value: "1d" },
|
||||
{ label: "7 days", value: "7d" },
|
||||
{ label: "30 days", value: "30d" },
|
||||
] as const
|
||||
|
||||
function getExpiryDate(preset: string): string | undefined {
|
||||
const now = Date.now()
|
||||
switch (preset) {
|
||||
case "1h":
|
||||
return new Date(now + 60 * 60 * 1000).toISOString()
|
||||
case "1d":
|
||||
return new Date(now + 24 * 60 * 60 * 1000).toISOString()
|
||||
case "7d":
|
||||
return new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
case "30d":
|
||||
return new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateInviteDialogProps {
|
||||
readonly open: boolean
|
||||
readonly onOpenChange: (open: boolean) => void
|
||||
readonly onCreated: () => void
|
||||
}
|
||||
|
||||
export function CreateInviteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: CreateInviteDialogProps) {
|
||||
const [role, setRole] = React.useState("office")
|
||||
const [maxUses, setMaxUses] = React.useState("")
|
||||
const [expiry, setExpiry] = React.useState("never")
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [createdUrl, setCreatedUrl] = React.useState<string | null>(null)
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await createInvite(
|
||||
role,
|
||||
maxUses ? parseInt(maxUses, 10) : undefined,
|
||||
getExpiryDate(expiry)
|
||||
)
|
||||
if (result.success && result.data) {
|
||||
const fullUrl = `${window.location.origin}${result.data.url}`
|
||||
setCreatedUrl(fullUrl)
|
||||
onCreated()
|
||||
} else {
|
||||
toast.error(result.error ?? "Failed to create invite")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Something went wrong")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
if (createdUrl) {
|
||||
navigator.clipboard.writeText(createdUrl)
|
||||
toast.success("Invite link copied")
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setCreatedUrl(null)
|
||||
setRole("office")
|
||||
setMaxUses("")
|
||||
setExpiry("never")
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{createdUrl ? "Invite Link Created" : "Create Invite Link"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{createdUrl
|
||||
? "Share this link with people you want to invite."
|
||||
: "Create a shareable link for your organization."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{createdUrl ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={createdUrl}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button size="icon" variant="outline" onClick={handleCopy}>
|
||||
<IconCopy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="office">Office</SelectItem>
|
||||
<SelectItem value="field">Field</SelectItem>
|
||||
<SelectItem value="client">Client</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Max uses (optional)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Unlimited"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Expires</Label>
|
||||
<Select value={expiry} onValueChange={setExpiry}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRY_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{createdUrl ? (
|
||||
<Button onClick={() => handleClose(false)}>Done</Button>
|
||||
) : (
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? "Creating..." : "Create Link"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
207
src/components/settings/invite-links-section.tsx
Normal file
207
src/components/settings/invite-links-section.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconCopy, IconTrash, IconPlus } from "@tabler/icons-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { getOrgInvites, revokeInvite } from "@/app/actions/invites"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateInviteDialog } from "./create-invite-dialog"
|
||||
|
||||
type InviteRow = {
|
||||
readonly id: string
|
||||
readonly code: string
|
||||
readonly role: string
|
||||
readonly maxUses: number | null
|
||||
readonly useCount: number
|
||||
readonly expiresAt: string | null
|
||||
readonly isActive: boolean
|
||||
readonly createdAt: string
|
||||
readonly createdByName: string | null
|
||||
}
|
||||
|
||||
function isExpired(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false
|
||||
return new Date(expiresAt) < new Date()
|
||||
}
|
||||
|
||||
function isExhausted(invite: InviteRow): boolean {
|
||||
return invite.maxUses !== null && invite.useCount >= invite.maxUses
|
||||
}
|
||||
|
||||
function formatExpiry(expiresAt: string | null): string {
|
||||
if (!expiresAt) return "Never"
|
||||
const date = new Date(expiresAt)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function InviteLinksSection() {
|
||||
const [invites, setInvites] = React.useState<InviteRow[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [createOpen, setCreateOpen] = React.useState(false)
|
||||
|
||||
const loadInvites = React.useCallback(async () => {
|
||||
try {
|
||||
const result = await getOrgInvites()
|
||||
if (result.success && result.data) {
|
||||
setInvites(result.data as InviteRow[])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load invites:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadInvites()
|
||||
}, [loadInvites])
|
||||
|
||||
const handleCopyLink = (code: string) => {
|
||||
const url = `${window.location.origin}/join/${code}`
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success("Invite link copied")
|
||||
}
|
||||
|
||||
const handleRevoke = async (inviteId: string) => {
|
||||
const result = await revokeInvite(inviteId)
|
||||
if (result.success) {
|
||||
toast.success("Invite revoked")
|
||||
await loadInvites()
|
||||
} else {
|
||||
toast.error(result.error ?? "Failed to revoke invite")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreated = () => {
|
||||
loadInvites()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Invite Links</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shareable links that let anyone join your organization
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<IconPlus className="mr-2 size-4" />
|
||||
Create Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invites.length === 0 ? (
|
||||
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
|
||||
No invite links yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created by</TableHead>
|
||||
<TableHead className="w-[100px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.map((invite) => {
|
||||
const expired = isExpired(invite.expiresAt)
|
||||
const exhausted = isExhausted(invite)
|
||||
const dimmed = !invite.isActive || expired || exhausted
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={invite.id}
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{invite.code}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{invite.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invite.useCount} / {invite.maxUses ?? "∞"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{expired ? (
|
||||
<span className="text-destructive">Expired</span>
|
||||
) : (
|
||||
formatExpiry(invite.expiresAt)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{invite.createdByName ?? "Unknown"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{!dimmed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => handleCopyLink(invite.code)}
|
||||
>
|
||||
<IconCopy className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{invite.isActive && !expired && !exhausted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-destructive"
|
||||
onClick={() => handleRevoke(invite.id)}
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateInviteDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -6,9 +6,11 @@ import { toast } from "sonner"
|
||||
|
||||
import { getUsers, deactivateUser, type UserWithRelations } from "@/app/actions/users"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { PeopleTable } from "@/components/people-table"
|
||||
import { UserDrawer } from "@/components/people/user-drawer"
|
||||
import { InviteDialog } from "@/components/people/invite-dialog"
|
||||
import { InviteLinksSection } from "@/components/settings/invite-links-section"
|
||||
|
||||
export function TeamTab() {
|
||||
const [users, setUsers] = React.useState<UserWithRelations[]>([])
|
||||
@ -101,6 +103,9 @@ export function TeamTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
<InviteLinksSection />
|
||||
|
||||
<UserDrawer
|
||||
user={selectedUser}
|
||||
open={drawerOpen}
|
||||
|
||||
@ -23,9 +23,36 @@ const allSchemas = {
|
||||
...conversationsSchema,
|
||||
}
|
||||
|
||||
/**
|
||||
* Null-safe stub returned when no D1 binding is available (local dev without CF).
|
||||
* Every property access returns a chainable proxy that resolves to empty results,
|
||||
* so server actions that forget to check `env?.DB` won't crash.
|
||||
*/
|
||||
function createNullDb(): ReturnType<typeof drizzle> {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
// .then — make the proxy non-thenable so `await proxy` returns the proxy itself
|
||||
if (prop === "then") return undefined
|
||||
// Common drizzle terminal methods — resolve to empty/noop
|
||||
if (prop === "all" || prop === "values") return async () => []
|
||||
if (prop === "get") return async () => undefined
|
||||
if (prop === "run") return async () => ({ changes: 0 })
|
||||
if (prop === "execute") return async () => []
|
||||
// findMany / findFirst on the relational query builder
|
||||
if (prop === "findMany") return async () => []
|
||||
if (prop === "findFirst") return async () => undefined
|
||||
// Everything else returns another proxy so chaining works:
|
||||
// db.select().from(t).where(...) etc.
|
||||
return new Proxy((..._args: unknown[]) => new Proxy({}, handler), handler)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler) as ReturnType<typeof drizzle>
|
||||
}
|
||||
|
||||
// Legacy function - kept for backwards compatibility
|
||||
// Prefer using the provider interface from ./provider for new code
|
||||
export function getDb(d1: D1Database) {
|
||||
if (!d1) return createNullDb()
|
||||
return drizzle(d1, { schema: allSchemas })
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
@ -63,11 +79,32 @@ export const agentUsage = sqliteTable("agent_usage", {
|
||||
createdAt: text("created_at").notNull(),
|
||||
})
|
||||
|
||||
// per-user Anthropic OAuth tokens (separate from provider config
|
||||
// because OAuth needs refresh token + expiry tracking)
|
||||
export const anthropicOauthTokens = sqliteTable(
|
||||
"anthropic_oauth_tokens",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}
|
||||
)
|
||||
|
||||
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 =
|
||||
typeof userModelPreference.$inferSelect
|
||||
export type NewUserModelPreference =
|
||||
typeof userModelPreference.$inferInsert
|
||||
export type AnthropicOauthToken =
|
||||
typeof anthropicOauthTokens.$inferSelect
|
||||
export type NewAnthropicOauthToken =
|
||||
typeof anthropicOauthTokens.$inferInsert
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
text,
|
||||
integer,
|
||||
} from "drizzle-orm/sqlite-core"
|
||||
import { users } from "./schema"
|
||||
import { users, organizations } from "./schema"
|
||||
|
||||
export const mcpApiKeys = sqliteTable("mcp_api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
@ -41,3 +41,28 @@ export type McpApiKey = typeof mcpApiKeys.$inferSelect
|
||||
export type NewMcpApiKey = typeof mcpApiKeys.$inferInsert
|
||||
export type McpUsage = typeof mcpUsage.$inferSelect
|
||||
export type NewMcpUsage = typeof mcpUsage.$inferInsert
|
||||
|
||||
export const mcpServers = sqliteTable("mcp_servers", {
|
||||
id: text("id").primaryKey(),
|
||||
orgId: text("org_id")
|
||||
.notNull()
|
||||
.references(() => organizations.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull(), // used as tool name prefix
|
||||
transport: text("transport").notNull(), // "stdio" | "http"
|
||||
command: text("command"), // stdio only
|
||||
args: text("args"), // stdio: JSON array
|
||||
env: text("env"), // stdio: JSON env vars
|
||||
url: text("url"), // http only
|
||||
headers: text("headers"), // http: JSON headers
|
||||
isEnabled: integer("is_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: text("created_at").notNull(),
|
||||
createdBy: text("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
})
|
||||
|
||||
export type McpServer = typeof mcpServers.$inferSelect
|
||||
export type NewMcpServer = typeof mcpServers.$inferInsert
|
||||
|
||||
@ -44,6 +44,23 @@ export const organizationMembers = sqliteTable("organization_members", {
|
||||
joinedAt: text("joined_at").notNull(),
|
||||
})
|
||||
|
||||
export const organizationInvites = sqliteTable("organization_invites", {
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organizations.id, { onDelete: "cascade" }),
|
||||
code: text("code").notNull().unique(),
|
||||
role: text("role").notNull().default("office"),
|
||||
maxUses: integer("max_uses"),
|
||||
useCount: integer("use_count").notNull().default(0),
|
||||
expiresAt: text("expires_at"),
|
||||
createdBy: text("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at").notNull(),
|
||||
})
|
||||
|
||||
export const teams = sqliteTable("teams", {
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
@ -238,6 +255,8 @@ export type Organization = typeof organizations.$inferSelect
|
||||
export type NewOrganization = typeof organizations.$inferInsert
|
||||
export type OrganizationMember = typeof organizationMembers.$inferSelect
|
||||
export type NewOrganizationMember = typeof organizationMembers.$inferInsert
|
||||
export type OrganizationInvite = typeof organizationInvites.$inferSelect
|
||||
export type NewOrganizationInvite = typeof organizationInvites.$inferInsert
|
||||
export type Team = typeof teams.$inferSelect
|
||||
export type NewTeam = typeof teams.$inferInsert
|
||||
export type TeamMember = typeof teamMembers.$inferSelect
|
||||
|
||||
385
src/hooks/use-agent.ts
Normal file
385
src/hooks/use-agent.ts
Normal file
@ -0,0 +1,385 @@
|
||||
"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 = "",
|
||||
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 {
|
||||
// Determine endpoint based on mode
|
||||
const isStandalone = agentServerUrl !== ""
|
||||
const endpoint = isStandalone
|
||||
? `${agentServerUrl}/agent/chat`
|
||||
: `/api/agent`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"x-session-id": sessionId,
|
||||
"x-current-page": currentPage,
|
||||
"x-timezone": timezone,
|
||||
"x-model": getAgentModelId(),
|
||||
}
|
||||
|
||||
// Standalone mode: JWT auth via server action
|
||||
// Cloud mode: WorkOS session cookie (same-origin, automatic)
|
||||
if (isStandalone) {
|
||||
const { getAgentToken } = await import("@/app/actions/agent-auth")
|
||||
const tokenResult = await getAgentToken()
|
||||
if ("error" in tokenResult) {
|
||||
throw new Error(tokenResult.error)
|
||||
}
|
||||
headers["Authorization"] = `Bearer ${tokenResult.token}`
|
||||
}
|
||||
|
||||
const allMessages = [...messages, userMessage]
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
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,50 @@ 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:
|
||||
typeof window !== "undefined" &&
|
||||
"__TAURI__" in window
|
||||
? "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 +111,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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user