mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a3b052124 | |||
| 425a4f584f | |||
| 7504daa602 | |||
| 9ac0f3e9ac | |||
| cf2cd13fb8 | |||
| 6883309997 | |||
| c3562a4a51 | |||
| 30a66dfc31 | |||
| fdc574ff81 | |||
| 1e5ddbd812 | |||
| 3109060216 | |||
| 9031ce7b51 | |||
| 2449b50585 | |||
| 66e6662440 | |||
| 56f3b9044d | |||
| 913659890d | |||
| 14e0b9b17f | |||
| e5524f5bf9 | |||
| 16234a0baf | |||
| e5cf3ad3c2 | |||
| 82da3320ba | |||
| aa553dea94 | |||
| 0c9cad86ec | |||
| 69910f361c | |||
| 7bafbb309a | |||
| ec26d78450 | |||
| 49e8d324dd | |||
| 60b9ba4db7 | |||
| a1a401dc9b | |||
| 2f1547ebaf | |||
| 62da1e7682 | |||
| 28a06e52fc | |||
| 5a5d981c4e | |||
| 62a7781e04 | |||
| 2d03cf4a9a | |||
| 4025d30b24 | |||
| dd3c443d8c | |||
| 4aaa9f9e30 | |||
| 31c805bc97 | |||
| 94f2ed1b84 | |||
| 86dc66eae9 | |||
| 9814dc6526 | |||
| f09c859974 | |||
| 1fcdb0246a | |||
| 340ad9e9ed | |||
| 76d814e747 | |||
| 9941e70d29 | |||
| 2afb812620 | |||
| ad2fc1ce4b | |||
| 5acc368ef4 | |||
| e2dc89c6f3 | |||
| e1581183ff | |||
| f195c952fc | |||
| 34b1045a01 | |||
| aa9956d46d | |||
| cec5a752bb | |||
| 0f0a4be253 | |||
| 0e88dd99e7 | |||
| f965db9e13 | |||
| 519d344470 | |||
| a78605f8ea | |||
| c0bc020ad6 | |||
| 0e669bbd18 | |||
| 00a6f228f2 | |||
| 0ba1081cf1 | |||
| 0448a30821 | |||
| 848d763d08 | |||
| fdfd0afed7 | |||
| 5f31d2ca88 | |||
| afdae3950a | |||
| 50b869a508 | |||
| cf8b03ec10 | |||
| 1554a5a82e | |||
| 797359fbf0 | |||
| 034298cb1f | |||
| 1c4196c358 | |||
| 28dbd4ab4a | |||
| 715589fea4 |
@@ -1,8 +1,6 @@
|
||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||
|
||||
## Commits and PR Titles
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -148,7 +148,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -170,7 +170,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "0.0.0",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -214,7 +214,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -227,8 +227,8 @@
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
@@ -275,7 +275,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -330,7 +330,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -374,7 +374,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -390,7 +390,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -403,7 +403,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -421,7 +421,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -437,8 +437,8 @@
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
@@ -478,6 +478,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
@@ -519,6 +520,7 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.21.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -559,7 +561,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -575,9 +577,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.15",
|
||||
"@opentui/keymap": ">=0.2.15",
|
||||
"@opentui/solid": ">=0.2.15",
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -597,7 +599,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -612,7 +614,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -625,7 +627,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -648,7 +650,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -667,7 +669,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -707,7 +709,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -756,7 +758,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -799,8 +801,9 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
@@ -823,9 +826,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -915,9 +918,9 @@
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.73", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-o2MuIeyvZrFIeIbnbA8Thrr63irdyUBh0uWBZ2lY6yFeXuE/tcwyXF74bDKS4KvTu84uFpQfpbS/LXHGKKXz+g=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.131", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/google": "3.0.75", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Oj1X8p0rVgvEoR5OOSxWi6XgzJ3QDlE/n30MZVtpKkCiToYYDyvlvVDGXz3IqhMyUev2JhlcuUk1brScKT01kA=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.128", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.77", "@ai-sdk/google": "3.0.73", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jK8fixb4km2yfgvb9DUFQRpV/jiDB0v9gyxHoHfPydaQvz+CpAz8DTt1quyaM+Wg9G2R8Zo68CYmHbIkUqW2AA=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||
|
||||
@@ -1715,23 +1718,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -5137,7 +5140,7 @@
|
||||
|
||||
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
|
||||
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
@@ -5229,9 +5232,11 @@
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="],
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.77", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
||||
|
||||
@@ -5493,6 +5498,8 @@
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "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-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
@@ -5955,6 +5962,8 @@
|
||||
|
||||
"miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
|
||||
|
||||
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
|
||||
|
||||
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
@@ -6071,6 +6080,8 @@
|
||||
|
||||
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
@@ -6171,6 +6182,10 @@
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Temporary destroy prep: keep only non-empty resources declared so SST updates forceDestroy before omission.
|
||||
new aws.s3tables.TableBucket("LakeTableBucket", {
|
||||
name: `opencode-${$app.stage}-lake`,
|
||||
forceDestroy: true,
|
||||
})
|
||||
|
||||
const athenaResultsBucket = new aws.s3.Bucket("LakeAthenaResults", {
|
||||
bucket: `opencode-${$app.stage}-lake-athena-results`,
|
||||
forceDestroy: true,
|
||||
})
|
||||
|
||||
new aws.s3.Bucket("LakeFirehoseErrors", {
|
||||
bucket: `opencode-${$app.stage}-lake-firehose-errors`,
|
||||
forceDestroy: true,
|
||||
})
|
||||
|
||||
new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
||||
name: `opencode-${$app.stage}-lake-workgroup`,
|
||||
forceDestroy: true,
|
||||
configuration: {
|
||||
enforceWorkgroupConfiguration: true,
|
||||
publishCloudwatchMetricsEnabled: true,
|
||||
resultConfiguration: {
|
||||
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||
},
|
||||
},
|
||||
})
|
||||
+2
-2
@@ -209,8 +209,8 @@ const ingestConfig = new sst.Linkable("LakeIngestConfig", {
|
||||
const ingestService = new sst.aws.Service("LakeIngestService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.5 vCPU",
|
||||
memory: "1 GB",
|
||||
cpu: "1 vCPU",
|
||||
memory: "4 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
|
||||
+1
-4
@@ -5,11 +5,8 @@ export const domain = (() => {
|
||||
})()
|
||||
|
||||
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||
// Dev owns the shared AWS lake/stats infra for all non-production stages.
|
||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||
export const prepareAwsDestroy = $app.stage === "production" || $app.stage === "dev"
|
||||
// Temporarily omit AWS infra so SST removes the lake/stats resources.
|
||||
export const deployAws = false
|
||||
export const deployAws = $app.stage === awsStage
|
||||
|
||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||
hostname: domain,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-p8EPw6YDdAaToGibLAljdtLuuSIqQ3mVJecYOwUWqY0=",
|
||||
"aarch64-linux": "sha256-G/xOunyVzqJucokje713GkysXgixJmce6jlevRht7Sc=",
|
||||
"aarch64-darwin": "sha256-zMeGIyWXUb0ofrUGxTluAytZMkTtlyEydIhbu62O2VM=",
|
||||
"x86_64-darwin": "sha256-ef7+QlnKmtB2fGOw3EFjlyZcgj+Q8XLbNVdPQiNNvh8="
|
||||
"x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
|
||||
"aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
|
||||
"aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
|
||||
"x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -10,7 +10,7 @@
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun run --cwd packages/stats/app dev",
|
||||
"dev:stats": "bun sst shell --stage=dev -- bun run --cwd packages/stats/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
@@ -38,9 +38,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -144,6 +144,7 @@
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
@@ -40,7 +41,7 @@ import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
@@ -48,11 +49,6 @@ import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { ServersProvider } from "./context/servers"
|
||||
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
|
||||
document.body.classList.remove("text-12-regular")
|
||||
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||
}
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
|
||||
@@ -97,9 +93,26 @@ function QueryProvider(props: ParentProps) {
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function BodyDesignClass() {
|
||||
const settings = useSettings()
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const enabled = settings.general.newLayoutDesigns()
|
||||
document.body.classList.toggle("text-12-regular", !enabled)
|
||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
||||
document.body.classList.toggle("text-[13px]", enabled)
|
||||
document.body.classList.toggle("font-[440]", enabled)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<BodyDesignClass />
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
|
||||
@@ -79,8 +79,6 @@ import { pathKey } from "@/utils/path-key"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
variant?: "dock" | "new-session"
|
||||
@@ -1456,7 +1454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={USE_V2_INPUT}>
|
||||
<Match when={settings.general.newLayoutDesigns()}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
@@ -1528,7 +1526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
|
||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
|
||||
@@ -45,8 +45,6 @@ const OPEN_APPS = [
|
||||
"sublime-text",
|
||||
] as const
|
||||
|
||||
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type OpenApp = (typeof OPEN_APPS)[number]
|
||||
type OS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
@@ -157,11 +155,11 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
|
||||
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
|
||||
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
|
||||
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
|
||||
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
|
||||
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
|
||||
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
|
||||
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
|
||||
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
|
||||
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
|
||||
@@ -399,6 +399,18 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
@@ -802,7 +814,7 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,6 @@ const legacyTitlebarHeight = 40
|
||||
const v2TitlebarHeight = 44
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||
|
||||
@@ -75,6 +74,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
@@ -85,7 +85,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||
const minHeight = () => {
|
||||
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
if (mac()) return `${height / zoom()}px`
|
||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||
return undefined
|
||||
@@ -119,7 +119,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
|
||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||
const version = props.update?.version()
|
||||
return {
|
||||
@@ -222,8 +222,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<header
|
||||
classList={{
|
||||
"shrink-0 relative overflow-hidden flex flex-row": true,
|
||||
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
|
||||
"h-10 bg-background-base": !USE_V2_TITLEBAR,
|
||||
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
|
||||
"h-10 bg-background-base": !useV2Titlebar(),
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
@@ -239,7 +239,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
onDblClick={maximize}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={USE_V2_TITLEBAR}>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSync = useServerSync()
|
||||
const navigate = useNavigate()
|
||||
@@ -490,7 +490,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
project={projectForSession(tab.info, projects(), projectByID())}
|
||||
directory={tab.dir}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
hideClose={tabsEnriched().length < 2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -737,14 +736,13 @@ function TabNavItem(props: {
|
||||
title: string
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const match = useMatch(() => props.href)
|
||||
const isActive = () => !!match()
|
||||
return (
|
||||
<div
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={isActive()}
|
||||
>
|
||||
<a
|
||||
@@ -755,7 +753,7 @@ function TabNavItem(props: {
|
||||
<span class="text-clip leading-5">{props.title}</span>
|
||||
</a>
|
||||
|
||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div
|
||||
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
@@ -766,7 +764,7 @@ function TabNavItem(props: {
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
|
||||
onClick={props.onClose}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showSessionProgressBar: boolean
|
||||
showCustomAgents: boolean
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -54,6 +55,7 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -242,6 +244,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowCustomAgents(value: boolean) {
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -791,6 +791,8 @@ export const dict = {
|
||||
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
|
||||
"settings.general.row.showSessionProgressBar.description":
|
||||
"Display the animated progress bar at the top of the session when the agent is working",
|
||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
|
||||
+109
-104
@@ -31,8 +31,8 @@ import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useServers } from "@/context/servers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const HOME_SESSION_LIMIT = 15
|
||||
const HOME_ROW =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
@@ -52,8 +52,12 @@ type HomeSessionGroup = {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
if (USE_HOME_DESIGN) return <HomeDesign />
|
||||
return <LegacyHome />
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
|
||||
<HomeDesign />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeDesign() {
|
||||
@@ -68,13 +72,11 @@ function HomeDesign() {
|
||||
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
|
||||
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
const selectedProject = createMemo(
|
||||
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
|
||||
)
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
|
||||
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return []
|
||||
if (!project) return [...projects().flatMap((project) => directories(project))]
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
@@ -89,8 +91,8 @@ function HomeDesign() {
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const records = createMemo(() =>
|
||||
[
|
||||
const records = createMemo(() => {
|
||||
return [
|
||||
...new Map(
|
||||
projectDirectories()
|
||||
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
|
||||
@@ -112,8 +114,8 @@ function HomeDesign() {
|
||||
if (!value) return true
|
||||
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
|
||||
})
|
||||
.slice(0, HOME_SESSION_LIMIT),
|
||||
)
|
||||
.slice(0, HOME_SESSION_LIMIT)
|
||||
})
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
|
||||
function selectProject(directory: string) {
|
||||
@@ -220,7 +222,7 @@ function HomeDesign() {
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<Show
|
||||
when={selectedProject()}
|
||||
when={projectDirectories().length > 0}
|
||||
fallback={
|
||||
<HomeEmptyState
|
||||
icon="folder-add-left"
|
||||
@@ -298,8 +300,8 @@ function HomeProjectColumn(props: {
|
||||
const layout = useLayout()
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
return (
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px] gap-4" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -311,8 +313,8 @@ function HomeProjectColumn(props: {
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</div>
|
||||
<For
|
||||
each={servers.list()}
|
||||
<Show
|
||||
when={servers.list().length > 1}
|
||||
fallback={
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
@@ -328,61 +330,63 @@ function HomeProjectColumn(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
<For each={servers.list()}>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
|
||||
return (
|
||||
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="relative h-7 group">
|
||||
<button
|
||||
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
|
||||
disabled={!healthy()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span>{server.displayName ?? new URL(server.http.url).host}</span>
|
||||
<Show when={healthy()}>
|
||||
<IconV2
|
||||
name="outline-chevron-down"
|
||||
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
|
||||
data-open={open()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<IconButtonV2
|
||||
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
|
||||
name="out"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
|
||||
/>
|
||||
return (
|
||||
<div class="max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="relative h-7 group">
|
||||
<button
|
||||
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
|
||||
disabled={!healthy()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span>{server.displayName ?? new URL(server.http.url).host}</span>
|
||||
<Show when={healthy()}>
|
||||
<IconV2
|
||||
name="outline-chevron-down"
|
||||
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
|
||||
data-open={open()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<IconButtonV2
|
||||
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
|
||||
name="out"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
|
||||
/>
|
||||
</div>
|
||||
<Show when={healthy() && open()}>
|
||||
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
selectedProject={props.selectedProject}
|
||||
onSelectedProjectChange={props.selectProject}
|
||||
onChooseProject={props.chooseProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
unseenCount={props.unseenCount}
|
||||
language={props.language}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={healthy() && open()}>
|
||||
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
selectedProject={props.selectedProject}
|
||||
onSelectedProjectChange={props.selectProject}
|
||||
onChooseProject={props.chooseProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
unseenCount={props.unseenCount}
|
||||
language={props.language}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div class="mt-4 flex min-w-0 flex-col gap-1">
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
@@ -416,12 +420,14 @@ function HomeProjectRow(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
|
||||
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
@@ -430,7 +436,37 @@ function HomeProjectRow(props: {
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span>{name()}</span>
|
||||
</button>
|
||||
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 peer-focus-visible:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={menuOpen()}
|
||||
>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={menuOpen()} onOpenChange={setMenuOpen}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
|
||||
{props.language.t("common.edit")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
@@ -442,37 +478,6 @@ function HomeProjectRow(props: {
|
||||
props.openNewSession(props.project.worktree)
|
||||
}}
|
||||
/>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end">
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="menu" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
|
||||
<Icon name="new-session" size="small" />
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
|
||||
<Icon name="edit" size="small" />
|
||||
{props.language.t("common.edit")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
|
||||
<Icon name="circle-check" size="small" />
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
|
||||
<Icon name="close" size="small" />
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -561,7 +566,7 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
|
||||
class="h-7 px-2 text-v2-text-text-muted"
|
||||
onClick={onNewSession()}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
|
||||
+160
-160
@@ -89,8 +89,6 @@ import {
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.global("layout.page", ["layout.page.v1"]),
|
||||
@@ -129,6 +127,7 @@ export default function Layout(props: ParentProps) {
|
||||
const command = useCommand()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
@@ -154,7 +153,7 @@ export default function Layout(props: ParentProps) {
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory && !USE_NEW_DESIGN,
|
||||
autoselect: !initialDirectory && !newDesign(),
|
||||
busyWorkspaces: {} as Record<string, boolean>,
|
||||
hoverProject: undefined as string | undefined,
|
||||
scrollSessionKey: undefined as string | undefined,
|
||||
@@ -1142,7 +1141,7 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
if (!USE_NEW_DESIGN)
|
||||
if (!newDesign())
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
@@ -1821,7 +1820,7 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--dialog-left-margin",
|
||||
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2363,179 +2362,180 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
)
|
||||
|
||||
if (USE_NEW_DESIGN) {
|
||||
return (
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
return (
|
||||
<Show
|
||||
when={!newDesign()}
|
||||
fallback={
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ const emptyFollowups: FollowupItem[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
@@ -198,6 +197,7 @@ export default function Page() {
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
@@ -265,7 +265,7 @@ export default function Page() {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const isV2NewSessionPage = () =>
|
||||
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
|
||||
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
@@ -1798,14 +1798,14 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
|
||||
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
|
||||
import { shouldUseV2NewSessionPage } from "./new-session-layout"
|
||||
|
||||
describe("shouldUseV2NewSessionPage", () => {
|
||||
test("keeps prod session pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
|
||||
test("keeps disabled pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
|
||||
})
|
||||
|
||||
test("uses the v2 layout only for non-prod new-session pages", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
|
||||
test("uses the v2 layout only for enabled new-session pages", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
|
||||
return input.channel !== "prod" && !input.sessionID
|
||||
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
|
||||
return input.newLayoutDesigns && !input.sessionID
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
@@ -60,7 +58,7 @@ export function SessionSidePanel(props: {
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
@@ -205,7 +203,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
|
||||
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
|
||||
@@ -20,8 +20,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
@@ -72,7 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
})
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const closableTab = tabState.closableTab
|
||||
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
|
||||
|
||||
const messages = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Modal } from "~/component/modal"
|
||||
import { IconCheck, IconCopy } from "~/component/icon"
|
||||
@@ -9,6 +10,7 @@ import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
|
||||
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
|
||||
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
|
||||
import "./go-referral.css"
|
||||
|
||||
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
|
||||
@@ -25,7 +27,21 @@ const emptyUsagePreview = {
|
||||
|
||||
export const queryGoReferral = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Referral.summary(), workspaceID)
|
||||
return withActor(async () => {
|
||||
const event = getRequestEvent()
|
||||
const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null)
|
||||
if (referralCode) {
|
||||
await Referral.createFromAccount({
|
||||
accountID: Actor.account(),
|
||||
referralCode,
|
||||
}).catch((error) => {
|
||||
console.error("Referral create failed", error)
|
||||
})
|
||||
event?.response.headers.append("set-cookie", clearReferralCookie())
|
||||
}
|
||||
|
||||
return Referral.summary()
|
||||
}, workspaceID)
|
||||
}, "go.referral.get")
|
||||
|
||||
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
|
||||
@@ -65,6 +81,8 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) {
|
||||
|
||||
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
|
||||
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
|
||||
if (reward.status === "pending" && reward.source === "inviter")
|
||||
return "workspace.referral.reward.source.pendingInviter" as const
|
||||
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
|
||||
return "workspace.referral.reward.action.view" as const
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.meta.description":
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وQwen3.5 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.hero.body":
|
||||
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
|
||||
@@ -298,7 +298,7 @@ export const dict = {
|
||||
"go.problem.item2": "حدود سخية ووصول موثوق",
|
||||
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
|
||||
"go.problem.item4":
|
||||
"يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash",
|
||||
"يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وQwen3.5 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash",
|
||||
"go.how.title": "كيف يعمل Go",
|
||||
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
|
||||
"go.how.step1.title": "أنشئ حسابًا",
|
||||
@@ -322,7 +322,7 @@ export const dict = {
|
||||
"go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.",
|
||||
"go.faq.q3": "هل Go هو نفسه Zen؟",
|
||||
"go.faq.a3":
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وQwen3.5 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||
"go.faq.q4": "كم تكلفة Go؟",
|
||||
"go.faq.a4.p1.beforePricing": "تكلفة Go",
|
||||
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
|
||||
@@ -345,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
|
||||
"go.faq.a9":
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وQwen3.5 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
|
||||
"go.meta.description":
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
||||
"go.hero.body":
|
||||
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
|
||||
@@ -303,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item2": "Limites generosos e acesso confiável",
|
||||
"go.problem.item3": "Feito para o maior número possível de programadores",
|
||||
"go.problem.item4":
|
||||
"Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash",
|
||||
"Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash",
|
||||
"go.how.title": "Como o Go funciona",
|
||||
"go.how.body":
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
|
||||
@@ -329,7 +329,7 @@ export const dict = {
|
||||
"go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.",
|
||||
"go.faq.q3": "O Go é o mesmo que o Zen?",
|
||||
"go.faq.a3":
|
||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Quanto custa o Go?",
|
||||
"go.faq.a4.p1.beforePricing": "O Go custa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
|
||||
"go.faq.a9":
|
||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
|
||||
"go.meta.description":
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
||||
"go.hero.body":
|
||||
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
|
||||
@@ -300,7 +300,7 @@ export const dict = {
|
||||
"go.problem.item2": "Generøse grænser og pålidelig adgang",
|
||||
"go.problem.item3": "Bygget til så mange programmører som muligt",
|
||||
"go.problem.item4":
|
||||
"Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash",
|
||||
"Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash",
|
||||
"go.how.title": "Hvordan Go virker",
|
||||
"go.how.body":
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
|
||||
@@ -326,7 +326,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.",
|
||||
"go.faq.q3": "Er Go det samme som Zen?",
|
||||
"go.faq.a3":
|
||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Hvad koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||
@@ -349,7 +349,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
|
||||
"go.faq.a9":
|
||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
|
||||
"go.meta.description":
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
||||
"go.hero.body":
|
||||
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
|
||||
@@ -302,7 +302,7 @@ export const dict = {
|
||||
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
|
||||
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
|
||||
"go.problem.item4":
|
||||
"Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash",
|
||||
"Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash",
|
||||
"go.how.title": "Wie Go funktioniert",
|
||||
"go.how.body":
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
|
||||
@@ -328,7 +328,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.",
|
||||
"go.faq.q3": "Ist Go dasselbe wie Zen?",
|
||||
"go.faq.a3":
|
||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Wie viel kostet Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kostet",
|
||||
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
|
||||
@@ -352,7 +352,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
|
||||
"go.faq.a9":
|
||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
||||
|
||||
@@ -248,7 +248,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
||||
"go.meta.description":
|
||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Low cost coding models for everyone",
|
||||
"go.hero.body":
|
||||
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
|
||||
@@ -296,7 +296,7 @@ export const dict = {
|
||||
"go.problem.item2": "Generous limits and reliable access",
|
||||
"go.problem.item3": "Built for as many programmers as possible",
|
||||
"go.problem.item4":
|
||||
"Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash",
|
||||
"Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash",
|
||||
"go.how.title": "How Go works",
|
||||
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
|
||||
"go.how.step1.title": "Create an account",
|
||||
@@ -321,7 +321,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.",
|
||||
"go.faq.q3": "Is Go the same as Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "How much does Go cost?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costs",
|
||||
"go.faq.a4.p1.pricingLink": "$5 first month",
|
||||
@@ -345,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "What is the difference between free models and Go?",
|
||||
"go.faq.a9":
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} is not supported",
|
||||
|
||||
@@ -254,7 +254,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
||||
"go.meta.description":
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
||||
"go.hero.body":
|
||||
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
|
||||
@@ -304,7 +304,7 @@ export const dict = {
|
||||
"go.problem.item2": "Límites generosos y acceso fiable",
|
||||
"go.problem.item3": "Creado para tantos programadores como sea posible",
|
||||
"go.problem.item4":
|
||||
"Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash",
|
||||
"Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash",
|
||||
"go.how.title": "Cómo funciona Go",
|
||||
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
|
||||
"go.how.step1.title": "Crear una cuenta",
|
||||
@@ -329,7 +329,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.",
|
||||
"go.faq.q3": "¿Es Go lo mismo que Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "¿Cuánto cuesta Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go cuesta",
|
||||
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
|
||||
"go.faq.a9":
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
|
||||
|
||||
@@ -255,7 +255,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
|
||||
"go.meta.description":
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.",
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Modèles de code à faible coût pour tous",
|
||||
"go.hero.body":
|
||||
"Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.",
|
||||
@@ -304,7 +304,7 @@ export const dict = {
|
||||
"go.problem.item2": "Limites généreuses et accès fiable",
|
||||
"go.problem.item3": "Conçu pour autant de programmeurs que possible",
|
||||
"go.problem.item4":
|
||||
"Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash",
|
||||
"Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash",
|
||||
"go.how.title": "Comment fonctionne Go",
|
||||
"go.how.body":
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
|
||||
@@ -330,7 +330,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.",
|
||||
"go.faq.q3": "Est-ce que Go est la même chose que Zen ?",
|
||||
"go.faq.a3":
|
||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.",
|
||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Combien coûte Go ?",
|
||||
"go.faq.a4.p1.beforePricing": "Go coûte",
|
||||
"go.faq.a4.p1.pricingLink": "$5 le premier mois",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
|
||||
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
|
||||
"go.faq.a9":
|
||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
|
||||
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
|
||||
"go.meta.description":
|
||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Modelli di coding a basso costo per tutti",
|
||||
"go.hero.body":
|
||||
"Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.",
|
||||
@@ -300,7 +300,7 @@ export const dict = {
|
||||
"go.problem.item2": "Limiti generosi e accesso affidabile",
|
||||
"go.problem.item3": "Costruito per il maggior numero possibile di programmatori",
|
||||
"go.problem.item4":
|
||||
"Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash",
|
||||
"Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash",
|
||||
"go.how.title": "Come funziona Go",
|
||||
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
|
||||
"go.how.step1.title": "Crea un account",
|
||||
@@ -325,7 +325,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.",
|
||||
"go.faq.q3": "Go è lo stesso di Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Quanto costa Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 il primo mese",
|
||||
@@ -349,7 +349,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
|
||||
"go.faq.a9":
|
||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
|
||||
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
|
||||
|
||||
@@ -250,7 +250,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
|
||||
"go.meta.description":
|
||||
"Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。",
|
||||
"Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。",
|
||||
"go.hero.title": "すべての人のための低価格なコーディングモデル",
|
||||
"go.hero.body":
|
||||
"Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。",
|
||||
@@ -300,7 +300,7 @@ export const dict = {
|
||||
"go.problem.item2": "十分な制限と安定したアクセス",
|
||||
"go.problem.item3": "できるだけ多くのプログラマーのために構築",
|
||||
"go.problem.item4":
|
||||
"GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む",
|
||||
"GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む",
|
||||
"go.how.title": "Goの仕組み",
|
||||
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
|
||||
"go.how.step1.title": "アカウントを作成",
|
||||
@@ -325,7 +325,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。",
|
||||
"go.faq.q3": "GoはZenと同じですか?",
|
||||
"go.faq.a3":
|
||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||
"go.faq.q4": "Goの料金は?",
|
||||
"go.faq.a4.p1.beforePricing": "Goは",
|
||||
"go.faq.a4.p1.pricingLink": "最初の月$5",
|
||||
@@ -349,7 +349,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "無料モデルとGoの違いは何ですか?",
|
||||
"go.faq.a9":
|
||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
|
||||
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
|
||||
|
||||
@@ -247,7 +247,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
|
||||
"go.meta.description":
|
||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||
"go.hero.title": "모두를 위한 저비용 코딩 모델",
|
||||
"go.hero.body":
|
||||
"Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.",
|
||||
@@ -297,7 +297,7 @@ export const dict = {
|
||||
"go.problem.item2": "넉넉한 한도와 안정적인 액세스",
|
||||
"go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨",
|
||||
"go.problem.item4":
|
||||
"GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함",
|
||||
"GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함",
|
||||
"go.how.title": "Go 작동 방식",
|
||||
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
|
||||
"go.how.step1.title": "계정 생성",
|
||||
@@ -321,7 +321,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.",
|
||||
"go.faq.q3": "Go는 Zen과 같은가요?",
|
||||
"go.faq.a3":
|
||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||
"go.faq.q4": "Go 비용은 얼마인가요?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 비용은",
|
||||
"go.faq.a4.p1.pricingLink": "첫 달 $5",
|
||||
@@ -344,7 +344,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
|
||||
"go.faq.a9":
|
||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
|
||||
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
|
||||
"go.meta.description":
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Rimelige kodemodeller for alle",
|
||||
"go.hero.body":
|
||||
"Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.",
|
||||
@@ -300,7 +300,7 @@ export const dict = {
|
||||
"go.problem.item2": "Rause grenser og pålitelig tilgang",
|
||||
"go.problem.item3": "Bygget for så mange programmerere som mulig",
|
||||
"go.problem.item4":
|
||||
"Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash",
|
||||
"Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash",
|
||||
"go.how.title": "Hvordan Go fungerer",
|
||||
"go.how.body":
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
|
||||
@@ -326,7 +326,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.",
|
||||
"go.faq.q3": "Er Go det samme som Zen?",
|
||||
"go.faq.a3":
|
||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Hva koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||
@@ -350,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
|
||||
"go.faq.a9":
|
||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
|
||||
|
||||
@@ -252,7 +252,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
|
||||
"go.meta.description":
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.",
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
|
||||
"go.hero.body":
|
||||
"Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.",
|
||||
@@ -301,7 +301,7 @@ export const dict = {
|
||||
"go.problem.item2": "Hojne limity i niezawodny dostęp",
|
||||
"go.problem.item3": "Stworzony dla jak największej liczby programistów",
|
||||
"go.problem.item4":
|
||||
"Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash",
|
||||
"Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash",
|
||||
"go.how.title": "Jak działa Go",
|
||||
"go.how.body":
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
|
||||
@@ -327,7 +327,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.",
|
||||
"go.faq.q3": "Czy Go to to samo co Zen?",
|
||||
"go.faq.a3":
|
||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.",
|
||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Ile kosztuje Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kosztuje",
|
||||
"go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc",
|
||||
@@ -351,7 +351,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
|
||||
"go.faq.a9":
|
||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
|
||||
|
||||
@@ -255,7 +255,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
|
||||
"go.meta.description":
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.",
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Недорогие модели для кодинга для всех",
|
||||
"go.hero.body":
|
||||
"Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.",
|
||||
@@ -305,7 +305,7 @@ export const dict = {
|
||||
"go.problem.item2": "Щедрые лимиты и надежный доступ",
|
||||
"go.problem.item3": "Создан для максимального числа программистов",
|
||||
"go.problem.item4":
|
||||
"Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash",
|
||||
"Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash",
|
||||
"go.how.title": "Как работает Go",
|
||||
"go.how.body":
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
|
||||
@@ -331,7 +331,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.",
|
||||
"go.faq.q3": "Go — это то же самое, что и Zen?",
|
||||
"go.faq.a3":
|
||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.",
|
||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Сколько стоит Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go стоит",
|
||||
"go.faq.a4.p1.pricingLink": "$5 за первый месяц",
|
||||
@@ -355,7 +355,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
|
||||
"go.faq.a9":
|
||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
|
||||
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
|
||||
|
||||
@@ -250,7 +250,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
"go.meta.description":
|
||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash",
|
||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash",
|
||||
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
"go.hero.body":
|
||||
"Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน",
|
||||
@@ -298,7 +298,7 @@ export const dict = {
|
||||
"go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้",
|
||||
"go.problem.item4":
|
||||
"รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash",
|
||||
"รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash",
|
||||
"go.how.title": "Go ทำงานอย่างไร",
|
||||
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
|
||||
"go.how.step1.title": "สร้างบัญชี",
|
||||
@@ -323,7 +323,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.faq.q3": "Go เหมือนกับ Zen หรือไม่?",
|
||||
"go.faq.a3":
|
||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้",
|
||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้",
|
||||
"go.faq.q4": "Go ราคาเท่าไหร่?",
|
||||
"go.faq.a4.p1.beforePricing": "Go ราคา",
|
||||
"go.faq.a4.p1.pricingLink": "$5 เดือนแรก",
|
||||
@@ -346,7 +346,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
|
||||
"go.faq.a9":
|
||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
|
||||
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
|
||||
"go.meta.description":
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.",
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.",
|
||||
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
|
||||
"go.hero.body":
|
||||
"Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.",
|
||||
@@ -303,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item2": "Cömert limitler ve güvenilir erişim",
|
||||
"go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi",
|
||||
"go.problem.item4":
|
||||
"GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir",
|
||||
"GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir",
|
||||
"go.how.title": "Go nasıl çalışır?",
|
||||
"go.how.body":
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
|
||||
@@ -329,7 +329,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.",
|
||||
"go.faq.q3": "Go, Zen ile aynı mı?",
|
||||
"go.faq.a3":
|
||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||
"go.faq.q4": "Go ne kadar?",
|
||||
"go.faq.a4.p1.beforePricing": "Go'nun maliyeti",
|
||||
"go.faq.a4.p1.pricingLink": "İlk ay $5",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
|
||||
"go.faq.a9":
|
||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
|
||||
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
|
||||
|
||||
@@ -252,7 +252,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
|
||||
"go.meta.description":
|
||||
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.",
|
||||
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Недорогі моделі кодування для всіх",
|
||||
"go.hero.body":
|
||||
"Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.",
|
||||
@@ -301,7 +301,7 @@ export const dict = {
|
||||
"go.problem.item2": "Щедрі ліміти та надійний доступ",
|
||||
"go.problem.item3": "Створено для якомога більшої кількості програмістів",
|
||||
"go.problem.item4":
|
||||
"Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash",
|
||||
"Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash",
|
||||
"go.how.title": "Як працює Go",
|
||||
"go.how.body":
|
||||
"Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.",
|
||||
@@ -350,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Яка різниця між безкоштовними моделями та Go?",
|
||||
"go.faq.a9":
|
||||
"Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.",
|
||||
"Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.",
|
||||
"zen.api.error.modelNotSupported": "Модель {{model}} не підтримується",
|
||||
|
||||
@@ -241,7 +241,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
|
||||
"go.meta.description":
|
||||
"Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。",
|
||||
"Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。",
|
||||
"go.hero.title": "人人可用的低成本编程模型",
|
||||
"go.hero.body":
|
||||
"Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。",
|
||||
@@ -289,7 +289,7 @@ export const dict = {
|
||||
"go.problem.item2": "充裕的限额和可靠的访问",
|
||||
"go.problem.item3": "为尽可能多的程序员打造",
|
||||
"go.problem.item4":
|
||||
"包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash",
|
||||
"包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash",
|
||||
"go.how.title": "Go 如何工作",
|
||||
"go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
|
||||
"go.how.step1.title": "创建账户",
|
||||
@@ -311,7 +311,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。",
|
||||
"go.faq.q3": "Go 和 Zen 一样吗?",
|
||||
"go.faq.a3":
|
||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。",
|
||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。",
|
||||
"go.faq.q4": "Go 多少钱?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 费用为",
|
||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||
@@ -333,7 +333,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
|
||||
"go.faq.a9":
|
||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
|
||||
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",
|
||||
|
||||
@@ -241,7 +241,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | 低成本全民編碼模型",
|
||||
"go.meta.description":
|
||||
"Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。",
|
||||
"Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。",
|
||||
"go.hero.title": "低成本全民編碼模型",
|
||||
"go.hero.body":
|
||||
"Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。",
|
||||
@@ -289,7 +289,7 @@ export const dict = {
|
||||
"go.problem.item2": "寬裕的限額與穩定存取",
|
||||
"go.problem.item3": "專為盡可能多的程式設計師打造",
|
||||
"go.problem.item4":
|
||||
"包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash",
|
||||
"包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash",
|
||||
"go.how.title": "Go 如何運作",
|
||||
"go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
|
||||
"go.how.step1.title": "建立帳號",
|
||||
@@ -311,7 +311,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。",
|
||||
"go.faq.q3": "Go 與 Zen 一樣嗎?",
|
||||
"go.faq.a3":
|
||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。",
|
||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。",
|
||||
"go.faq.q4": "Go 費用是多少?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 費用為",
|
||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||
@@ -333,7 +333,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "免費模型與 Go 有什麼區別?",
|
||||
"go.faq.a9":
|
||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
|
||||
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { redirect } from "@solidjs/router"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest, route } from "~/lib/language"
|
||||
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
@@ -19,7 +17,6 @@ export async function GET(input: APIEvent) {
|
||||
if (result.err) throw new Error(result.err.message)
|
||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||
if (decoded.err) throw new Error(decoded.err.message)
|
||||
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
|
||||
const session = await useAuthSession()
|
||||
const id = decoded.subject.properties.accountID
|
||||
await session.update((value) => {
|
||||
@@ -35,15 +32,8 @@ export async function GET(input: APIEvent) {
|
||||
current: id,
|
||||
}
|
||||
})
|
||||
if (decoded.subject.properties.newAccount && referralCode) {
|
||||
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
|
||||
console.error("Referral create failed", error)
|
||||
})
|
||||
}
|
||||
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
|
||||
const response = redirect(route(locale, next))
|
||||
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
|
||||
return response
|
||||
return redirect(route(locale, next))
|
||||
} catch (e: any) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -29,8 +29,9 @@ const models = [
|
||||
{ name: "Kimi K2.6", provider: "Moonshot AI" },
|
||||
{ name: "MiMo-V2.5-Pro", provider: "Xiaomi MiMo" },
|
||||
{ name: "MiMo-V2.5", provider: "Xiaomi MiMo" },
|
||||
{ name: "Qwen3.5 Plus", provider: "Alibaba Cloud Model Studio" },
|
||||
{ name: "Qwen3.7 Max", provider: "Alibaba Cloud Model Studio" },
|
||||
{ name: "Qwen3.6 Plus", provider: "Alibaba Cloud Model Studio" },
|
||||
{ name: "Qwen3.5 Plus", provider: "Alibaba Cloud Model Studio" },
|
||||
{ name: "MiniMax M2.7", provider: "MiniMax" },
|
||||
{ name: "MiniMax M2.5", provider: "MiniMax" },
|
||||
{ name: "DeepSeek V4 Pro", provider: "DeepSeek" },
|
||||
@@ -61,17 +62,18 @@ function LimitsGraph(props: { href: string }) {
|
||||
const free = 200
|
||||
const graph = [
|
||||
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
|
||||
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "280ms" },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "150ms" },
|
||||
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" },
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" },
|
||||
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
|
||||
]
|
||||
|
||||
const w = 720
|
||||
const h = 330
|
||||
const left = 40
|
||||
const right = 60
|
||||
const top = 18
|
||||
@@ -107,6 +109,7 @@ function LimitsGraph(props: { href: string }) {
|
||||
const bh = 8
|
||||
const gap = 20
|
||||
const step = bh + gap
|
||||
const h = 330 + Math.max(0, graph.length - 8) * step
|
||||
const sep = bh + 40
|
||||
const fy = top + 22
|
||||
const gy = (i: number) => fy + sep + step * i
|
||||
|
||||
@@ -268,6 +268,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
|
||||
<li>MiniMax M2.7</li>
|
||||
<li>Qwen3.5 Plus</li>
|
||||
<li>Qwen3.6 Plus</li>
|
||||
<li>Qwen3.7 Max</li>
|
||||
<li>DeepSeek V4 Pro</li>
|
||||
<li>DeepSeek V4 Flash</li>
|
||||
</ul>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import { Key } from "@opencode-ai/console-core/key.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||
import styles from "./key-section.module.css"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
@@ -124,7 +123,6 @@ export function KeySection() {
|
||||
<th>{i18n.t("workspace.keys.table.name")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.key")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -156,9 +154,6 @@ export function KeySection() {
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="key-user-email">{key.email}</td>
|
||||
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
|
||||
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
|
||||
</td>
|
||||
<td data-slot="key-actions">
|
||||
<form action={removeKey} method="post">
|
||||
<input type="hidden" name="id" value={key.id} />
|
||||
|
||||
@@ -483,28 +483,29 @@ export async function handler(
|
||||
modelTpsLimits: Record<string, boolean> | undefined,
|
||||
) {
|
||||
const modelProvider = (() => {
|
||||
const allProviders = modelInfo.providers.filter((provider) => !provider.disabled)
|
||||
|
||||
// Byok is top priority b/c if user set their own API key, we should use it
|
||||
// instead of using the sticky provider for the same session
|
||||
if (authInfo?.provider?.credentials) {
|
||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
||||
return allProviders.find((provider) => provider.id === modelInfo.byokProvider)
|
||||
}
|
||||
|
||||
// Always use the same provider for the same session
|
||||
if (stickyProvider) {
|
||||
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
|
||||
const provider = allProviders.find((provider) => provider.id === stickyProvider)
|
||||
if (provider) return provider
|
||||
}
|
||||
|
||||
if (trialProviders) {
|
||||
const trialProvider = trialProviders[Math.floor(Math.random() * trialProviders.length)]
|
||||
const provider = modelInfo.providers.find((provider) => provider.id === trialProvider)
|
||||
const provider = allProviders.find((provider) => provider.id === trialProvider)
|
||||
if (provider) return provider
|
||||
}
|
||||
|
||||
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
||||
let topPriority = Infinity
|
||||
const providers = modelInfo.providers
|
||||
.filter((provider) => !provider.disabled)
|
||||
const providers = allProviders
|
||||
.filter((provider) => provider.weight !== 0)
|
||||
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
||||
.filter((provider) => {
|
||||
@@ -536,7 +537,7 @@ export async function handler(
|
||||
}
|
||||
|
||||
// fallback provider
|
||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
||||
return allProviders.find((provider) => provider.id === modelInfo.fallbackProvider)
|
||||
})()
|
||||
|
||||
if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable"))
|
||||
@@ -988,10 +989,6 @@ export async function handler(
|
||||
return undefined
|
||||
})(),
|
||||
}),
|
||||
db
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
...(() => {
|
||||
if (billingSource === "subscription") {
|
||||
const plan = authInfo.billing.subscription!.plan
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { FreeUsageLimitError } from "./error"
|
||||
import { logger } from "./logger"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
@@ -22,7 +20,6 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
const lifetimeInterval = ""
|
||||
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
|
||||
const retryAfter = getRetryAfterDay(now)
|
||||
const redis = getRedis()
|
||||
@@ -32,33 +29,12 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const [counts, rows] = await Promise.all([
|
||||
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||
.from(IpRateLimitTable)
|
||||
.where(
|
||||
and(
|
||||
eq(IpRateLimitTable.ip, ip),
|
||||
isDefaultModel
|
||||
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
|
||||
: inArray(IpRateLimitTable.interval, [dailyInterval]),
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
|
||||
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
|
||||
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
|
||||
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
|
||||
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
|
||||
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
|
||||
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||
const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
|
||||
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
|
||||
|
||||
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
||||
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
|
||||
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
|
||||
|
||||
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
|
||||
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
|
||||
@@ -68,18 +44,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
pipeline.incr(dailyKey)
|
||||
pipeline.expire(dailyKey, retryAfter)
|
||||
if (isNew) pipeline.incr(lifetimeKey)
|
||||
await Promise.all([
|
||||
pipeline.exec().catch(() => {}),
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.insert(IpRateLimitTable)
|
||||
.values([
|
||||
{ ip, interval: dailyInterval, count: 1 },
|
||||
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
|
||||
])
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||
),
|
||||
])
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { RateLimitError } from "./error"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
|
||||
@@ -19,26 +18,20 @@ export function createRateLimiter(
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12)
|
||||
const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}`
|
||||
const redis = getRedis()
|
||||
const key = buildRateLimitKey("key", zenApiKey, interval)
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count })
|
||||
.from(KeyRateLimitTable)
|
||||
.where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))),
|
||||
).then((rows) => rows[0])
|
||||
const count = rows?.count ?? 0
|
||||
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
|
||||
|
||||
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
|
||||
},
|
||||
track: async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(KeyRateLimitTable)
|
||||
.values({ key: zenApiKey, interval, count: 1 })
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
|
||||
)
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.incr(key)
|
||||
pipeline.expire(key, 60)
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
port: 3001,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod"
|
||||
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
|
||||
import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle"
|
||||
import { Actor } from "./actor"
|
||||
import { Identifier } from "./identifier"
|
||||
import { LiteTable } from "./schema/billing.sql"
|
||||
import { LiteTable, PaymentTable } from "./schema/billing.sql"
|
||||
import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
@@ -318,6 +318,26 @@ export namespace Referral {
|
||||
.then((rows) => rows[0])
|
||||
if (selfReferral) throw new Error("Self-referral is not allowed")
|
||||
|
||||
const workspaceIDs = await tx
|
||||
.select({ workspaceID: UserTable.workspaceID })
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted)))
|
||||
.then((rows) => rows.map((row) => row.workspaceID))
|
||||
if (workspaceIDs.length === 0) return
|
||||
|
||||
const litePayment = await tx
|
||||
.select({ id: PaymentTable.id })
|
||||
.from(PaymentTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(PaymentTable.workspaceID, workspaceIDs),
|
||||
isNull(PaymentTable.timeDeleted),
|
||||
sql`JSON_UNQUOTE(JSON_EXTRACT(${PaymentTable.enrichment}, '$.type')) = 'lite'`,
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (litePayment) return
|
||||
|
||||
const referralID = Identifier.create("referral")
|
||||
await tx.insert(ReferralTable).ignore().values({
|
||||
workspaceID: code.workspaceID,
|
||||
@@ -355,7 +375,7 @@ export namespace Referral {
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error("Referral not found")
|
||||
if (!referral) return
|
||||
|
||||
const result = await tx
|
||||
.insert(ReferralRewardTable)
|
||||
@@ -373,7 +393,7 @@ export namespace Referral {
|
||||
},
|
||||
])
|
||||
|
||||
if (result.rowsAffected === 0) throw new Error("Referral already completed")
|
||||
if (result.rowsAffected === 0) return
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "0.0.0",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -34,8 +34,8 @@
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: true,
|
||||
port: 3002,
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.10"
|
||||
version = "1.15.11"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -81,8 +81,8 @@
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
@@ -122,6 +122,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
@@ -163,6 +164,7 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.21.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { ACPNextPermission } from "./permission"
|
||||
import {
|
||||
duplicateRunningToolUpdate,
|
||||
errorToolUpdate,
|
||||
@@ -22,7 +23,8 @@ import {
|
||||
|
||||
const log = Log.create({ service: "acp-next-event" })
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate">
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
type GlobalEventEnvelope = {
|
||||
payload?: Event
|
||||
}
|
||||
@@ -40,6 +42,7 @@ export class Subscription {
|
||||
private readonly abort = new AbortController()
|
||||
private readonly shellSnapshots = new Map<string, string>()
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private readonly permission: ACPNextPermission.Handler
|
||||
private started = false
|
||||
|
||||
constructor(
|
||||
@@ -48,7 +51,9 @@ export class Subscription {
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
},
|
||||
) {}
|
||||
) {
|
||||
this.permission = new ACPNextPermission.Handler(input)
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) return
|
||||
@@ -65,6 +70,9 @@ export class Subscription {
|
||||
|
||||
async handle(event: Event) {
|
||||
switch (event.type) {
|
||||
case "permission.asked":
|
||||
this.permission.handle(event)
|
||||
return
|
||||
case "message.part.updated":
|
||||
return this.handlePartUpdated(event)
|
||||
case "message.part.delta":
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { exists, readText } from "@/util/filesystem"
|
||||
import type { ACPNextSession } from "./session"
|
||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const log = Log.create({ service: "acp-next-permission" })
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
export class Handler {
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
sdk: OpencodeClient
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
},
|
||||
) {}
|
||||
|
||||
handle(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = previous
|
||||
.then(() => this.process(event))
|
||||
.catch((error: unknown) => {
|
||||
log.error("failed to handle permission", { error, permissionID: permission.id })
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.queues.get(permission.sessionID) === next) {
|
||||
this.queues.delete(permission.sessionID)
|
||||
}
|
||||
})
|
||||
this.queues.set(permission.sessionID, next)
|
||||
}
|
||||
|
||||
private async process(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
if (!session) return
|
||||
|
||||
if (!this.input.connection.requestPermission) {
|
||||
log.error("ACP connection cannot request permission", {
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
log.error("failed to request permission from ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
|
||||
const reply = selectedReply(result)
|
||||
if (reply !== "once" && reply !== "always") {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
if (permission.permission === "edit") {
|
||||
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
|
||||
log.error("failed to write proposed edit through ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.reply(permission.id, reply, session.cwd)
|
||||
}
|
||||
|
||||
private async reply(requestID: string, reply: Reply, directory: string) {
|
||||
await this.input.sdk.permission.reply({
|
||||
requestID,
|
||||
reply,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
|
||||
const filepath = stringValue(metadata.filepath)
|
||||
const diff = stringValue(metadata.diff)
|
||||
if (!filepath || !diff || !this.input.connection.writeTextFile) return
|
||||
|
||||
const content = (await exists(filepath)) ? await readText(filepath) : ""
|
||||
const next = applyPatch(content, diff)
|
||||
if (next === false) {
|
||||
log.error("Failed to apply unified diff (context mismatch)")
|
||||
return
|
||||
}
|
||||
|
||||
void this.input.connection.writeTextFile({
|
||||
sessionId,
|
||||
path: filepath,
|
||||
content: next,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectedReply(result: RequestPermissionResponse): Reply {
|
||||
if (result.outcome.outcome !== "selected") return "reject"
|
||||
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
|
||||
return "reject"
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as ACPNextPermission from "./permission"
|
||||
@@ -0,0 +1,42 @@
|
||||
const enabled = process.env.OPENCODE_ACP_PROFILE === "1"
|
||||
const started = performance.now()
|
||||
|
||||
export function mark(name: string, fields?: Record<string, string | number | boolean | undefined>) {
|
||||
if (!enabled) return
|
||||
write(`${name}.mark`, performance.now() - started, fields)
|
||||
}
|
||||
|
||||
export function duration(
|
||||
name: string,
|
||||
startedAt: number,
|
||||
fields?: Record<string, string | number | boolean | undefined>,
|
||||
) {
|
||||
if (!enabled) return
|
||||
write(name, performance.now() - startedAt, fields)
|
||||
}
|
||||
|
||||
export async function measure<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
fields?: Record<string, string | number | boolean | undefined>,
|
||||
) {
|
||||
if (!enabled) return fn()
|
||||
const start = performance.now()
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
write(name, performance.now() - start, fields)
|
||||
}
|
||||
}
|
||||
|
||||
function write(name: string, durationMs: number, fields?: Record<string, string | number | boolean | undefined>) {
|
||||
const extra = fields
|
||||
? Object.entries(fields)
|
||||
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ")
|
||||
: ""
|
||||
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
|
||||
}
|
||||
|
||||
export * as ACPNextProfile from "./profile"
|
||||
@@ -35,9 +35,12 @@ import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ACPNextError from "./error"
|
||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||
import { promptContentToParts } from "./content"
|
||||
import { Directory } from "./directory"
|
||||
import { ACPNextEvent } from "./event"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { UsageService } from "./usage"
|
||||
import { ACPNextProfile } from "./profile"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import type { Command } from "@/command"
|
||||
@@ -46,6 +49,8 @@ export const AuthMethodID = "opencode-login"
|
||||
const log = Log.create({ service: "acp-next-service" })
|
||||
|
||||
export type Error = ACPNextError.Error
|
||||
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
|
||||
export type Interface = {
|
||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
|
||||
@@ -69,20 +74,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
|
||||
|
||||
export function make(input: {
|
||||
sdk: OpencodeClient
|
||||
connection?: Pick<AgentSideConnection, "sessionUpdate">
|
||||
connection?: ServiceConnection
|
||||
directory?: Directory.Interface
|
||||
session?: ACPNextSession.Interface
|
||||
usage?: UsageService.Interface
|
||||
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
|
||||
}): Interface {
|
||||
const session = input.session ?? makeSessionService()
|
||||
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const sessionSnapshots = new Map<string, Directory.Snapshot>()
|
||||
const events = input.connection
|
||||
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
|
||||
: undefined
|
||||
if (events) input.eventSubscription?.(events)
|
||||
|
||||
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
|
||||
const started = performance.now()
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -99,7 +107,7 @@ export function make(input: {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const response = {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
@@ -124,6 +132,8 @@ export function make(input: {
|
||||
version: InstallationVersion,
|
||||
},
|
||||
}
|
||||
ACPNextProfile.duration("acp.initialize", started)
|
||||
return response
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
|
||||
@@ -134,15 +144,28 @@ export function make(input: {
|
||||
})
|
||||
|
||||
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
|
||||
return yield* directoryService.get(cwd)
|
||||
const started = performance.now()
|
||||
const snapshot = yield* directoryService.get(cwd)
|
||||
ACPNextProfile.duration("acp.directory.snapshot", started)
|
||||
return snapshot
|
||||
})
|
||||
|
||||
const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
|
||||
const snapshot = sessionSnapshots.get(state.id)
|
||||
if (snapshot) return snapshot
|
||||
const loaded = yield* directorySnapshot(state.cwd)
|
||||
sessionSnapshots.set(state.id, loaded)
|
||||
return loaded
|
||||
})
|
||||
|
||||
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
|
||||
const started = performance.now()
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
const selected = selectDefaultModel(snapshot)
|
||||
const variant = selectVariant(snapshot, selected)
|
||||
const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
|
||||
const created = yield* request(
|
||||
const created = yield* profiledRequest(
|
||||
"acp.newSession.session.create",
|
||||
() =>
|
||||
input.sdk.session.create(
|
||||
{
|
||||
@@ -166,11 +189,12 @@ export function make(input: {
|
||||
variant,
|
||||
modeId,
|
||||
})
|
||||
sessionSnapshots.set(state.id, snapshot)
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
|
||||
return {
|
||||
const response = {
|
||||
sessionId: state.id,
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? selected,
|
||||
@@ -178,6 +202,8 @@ export function make(input: {
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
ACPNextProfile.duration("acp.newSession", started)
|
||||
return response
|
||||
})
|
||||
|
||||
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
|
||||
@@ -204,6 +230,7 @@ export function make(input: {
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
sessionSnapshots.set(state.id, snapshot)
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
@@ -232,21 +259,35 @@ export function make(input: {
|
||||
),
|
||||
"session",
|
||||
)
|
||||
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
const serverEntries = sessions.map(
|
||||
(item): SessionInfo => ({
|
||||
sessionId: item.id,
|
||||
cwd: item.directory,
|
||||
title: item.title,
|
||||
updatedAt: new Date(item.time.updated).toISOString(),
|
||||
}),
|
||||
)
|
||||
const liveEntries = (yield* session.list(params.cwd ?? undefined))
|
||||
.filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id))
|
||||
.map(
|
||||
(item): SessionInfo => ({
|
||||
sessionId: item.id,
|
||||
cwd: item.cwd,
|
||||
updatedAt: item.createdAt.toISOString(),
|
||||
}),
|
||||
)
|
||||
const sorted = [...liveEntries, ...serverEntries].toSorted(
|
||||
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(),
|
||||
)
|
||||
const filtered =
|
||||
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
|
||||
cursor === undefined || !Number.isFinite(cursor)
|
||||
? sorted
|
||||
: sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor)
|
||||
const page = filtered.slice(0, limit)
|
||||
const last = page.at(-1)
|
||||
return {
|
||||
sessions: page.map(
|
||||
(item): SessionInfo => ({
|
||||
sessionId: item.id,
|
||||
cwd: item.directory,
|
||||
title: item.title,
|
||||
updatedAt: new Date(item.time.updated).toISOString(),
|
||||
}),
|
||||
),
|
||||
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
|
||||
sessions: page,
|
||||
...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -274,6 +315,7 @@ export function make(input: {
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
sessionSnapshots.set(state.id, snapshot)
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
@@ -291,6 +333,7 @@ export function make(input: {
|
||||
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
|
||||
const removed = yield* session.remove(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
sessionSnapshots.delete(params.sessionId)
|
||||
if (!removed) return {}
|
||||
|
||||
yield* request(
|
||||
@@ -334,6 +377,7 @@ export function make(input: {
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
sessionSnapshots.set(state.id, snapshot)
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
@@ -353,7 +397,7 @@ export function make(input: {
|
||||
params: SetSessionConfigOptionRequest,
|
||||
) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
if (typeof params.value !== "string") {
|
||||
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
@@ -408,7 +452,7 @@ export function make(input: {
|
||||
|
||||
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
|
||||
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
|
||||
}
|
||||
@@ -418,7 +462,7 @@ export function make(input: {
|
||||
|
||||
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
const selected = yield* parseSelectedModel(snapshot, params.modelId)
|
||||
yield* session
|
||||
.setVariant(
|
||||
@@ -443,8 +487,81 @@ export function make(input: {
|
||||
setSessionConfigOption,
|
||||
setSessionMode,
|
||||
setSessionModel,
|
||||
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
|
||||
prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const selected = current.model ?? selectDefaultModel(snapshot)
|
||||
if (!current.model) {
|
||||
yield* session.setModel(params.sessionId, selected)
|
||||
}
|
||||
const variant = current.variant ?? selectVariant(snapshot, selected)
|
||||
const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined)
|
||||
const parts = promptContentToParts(params.prompt)
|
||||
const command = detectSlashCommand(parts)
|
||||
|
||||
if (!command) {
|
||||
const response = yield* request(
|
||||
() =>
|
||||
input.sdk.session.prompt(
|
||||
{
|
||||
sessionID: current.id,
|
||||
model: {
|
||||
providerID: selected.providerID,
|
||||
modelID: selected.modelID,
|
||||
},
|
||||
...(variant ? { variant } : {}),
|
||||
parts,
|
||||
...(modeId ? { agent: modeId } : {}),
|
||||
directory: current.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(response.info, params.messageId)
|
||||
}
|
||||
|
||||
const known = snapshot.availableCommands.find((item) => item.name === command.name)
|
||||
if (known) {
|
||||
const response = yield* request(
|
||||
() =>
|
||||
input.sdk.session.command(
|
||||
{
|
||||
sessionID: current.id,
|
||||
command: known.name,
|
||||
arguments: command.args,
|
||||
model: `${selected.providerID}/${selected.modelID}`,
|
||||
...(variant ? { variant } : {}),
|
||||
...(modeId ? { agent: modeId } : {}),
|
||||
directory: current.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(response.info, params.messageId)
|
||||
}
|
||||
|
||||
if (command.name === "compact") {
|
||||
yield* request(
|
||||
() =>
|
||||
input.sdk.session.summarize(
|
||||
{
|
||||
sessionID: current.id,
|
||||
directory: current.cwd,
|
||||
providerID: selected.providerID,
|
||||
modelID: selected.modelID,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
}
|
||||
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(undefined, params.messageId)
|
||||
}),
|
||||
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
|
||||
@@ -473,6 +590,91 @@ function makeDirectoryService(sdk: OpencodeClient) {
|
||||
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
|
||||
}
|
||||
|
||||
function makeUsageService(sdk: OpencodeClient) {
|
||||
const limits = new Map<string, Promise<number | undefined>>()
|
||||
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(
|
||||
function* (params) {
|
||||
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
|
||||
const current = limits.get(key)
|
||||
if (current) return yield* Effect.promise(() => current)
|
||||
|
||||
const next = sdk.config
|
||||
.providers({ directory: params.directory }, { throwOnError: true })
|
||||
.then((response) => {
|
||||
const providers = Object.fromEntries(
|
||||
(response.data?.providers ?? []).map((provider) => [provider.id, provider]),
|
||||
) as Record<ProviderID, Provider.Info>
|
||||
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
log.error("failed to get providers for usage context limit", { error })
|
||||
return undefined
|
||||
})
|
||||
limits.set(key, next)
|
||||
return yield* Effect.promise(() => next)
|
||||
},
|
||||
)
|
||||
|
||||
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(
|
||||
function* (params) {
|
||||
const messages = yield* request(
|
||||
() =>
|
||||
sdk.session.messages(
|
||||
{
|
||||
sessionID: params.sessionID,
|
||||
directory: params.directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
).pipe(
|
||||
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to fetch messages for usage update", { error })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!messages) return
|
||||
|
||||
const message = UsageService.latestAssistantMessage(messages)
|
||||
if (!message?.providerID || !message.modelID) return
|
||||
|
||||
const size = yield* contextLimit({
|
||||
directory: params.directory,
|
||||
providerID: ProviderID.make(message.providerID),
|
||||
modelID: ModelID.make(message.modelID),
|
||||
})
|
||||
if (!size) return
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
params.connection
|
||||
.sessionUpdate({
|
||||
sessionId: params.sessionID,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: message.tokens.input + message.tokens.cache.read,
|
||||
size,
|
||||
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send usage update", { error })
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
return UsageService.Service.of({
|
||||
buildUsage: UsageService.buildUsage,
|
||||
latestAssistantMessage: UsageService.latestAssistantMessage,
|
||||
totalSessionCost: UsageService.totalSessionCost,
|
||||
contextLimit,
|
||||
sendUpdate,
|
||||
})
|
||||
}
|
||||
|
||||
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
|
||||
if (!subscription) return Effect.void
|
||||
return Effect.promise(async () => {
|
||||
@@ -505,6 +707,8 @@ type MessageInfo = {
|
||||
readonly agent?: Message["agent"]
|
||||
}
|
||||
|
||||
type AssistantInfo = UsageService.AssistantTokenCost | undefined
|
||||
|
||||
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
@@ -519,66 +723,79 @@ function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
|
||||
const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([
|
||||
sdk.config.providers({ directory }, { throwOnError: true }),
|
||||
sdk.app.agents({ directory }, { throwOnError: true }),
|
||||
sdk.command.list({ directory }, { throwOnError: true }),
|
||||
sdk.app.skills({ directory }, { throwOnError: true }),
|
||||
])
|
||||
const providersData = providersResponse.data!
|
||||
const agents = agentsResponse.data!
|
||||
const commandsData = commandsResponse.data!
|
||||
const skills = skillsResponse.data!
|
||||
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
|
||||
ProviderID,
|
||||
Provider.Info
|
||||
>
|
||||
const defaultModel = await defaultModelFromSdk(sdk, directory, providers)
|
||||
const modes = agents
|
||||
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
|
||||
.map((agent) => ({
|
||||
id: agent.name,
|
||||
name: agent.name,
|
||||
...(agent.description ? { description: agent.description } : {}),
|
||||
}))
|
||||
const commands = [
|
||||
...commandsData,
|
||||
...skills
|
||||
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
|
||||
.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "skill" as const,
|
||||
template: skill.content,
|
||||
hints: [],
|
||||
})),
|
||||
] as Command.Info[]
|
||||
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return request(() => ACPNextProfile.measure(name, fn), service)
|
||||
}
|
||||
|
||||
return Directory.build({
|
||||
directory,
|
||||
providers,
|
||||
modes,
|
||||
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
|
||||
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
...(defaultModel ? { defaultModel } : {}),
|
||||
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
|
||||
return ACPNextProfile.measure("acp.directory.load", async () => {
|
||||
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
|
||||
ACPNextProfile.measure("acp.directory.provider.list", () =>
|
||||
sdk.config.providers({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () =>
|
||||
sdk.app.agents({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.command.list", () =>
|
||||
sdk.command.list({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
|
||||
ACPNextProfile.measure("acp.directory.defaultModel.config", () =>
|
||||
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
|
||||
),
|
||||
])
|
||||
const providersData = providersResponse.data!
|
||||
const agents = agentsResponse.data!
|
||||
const commandsData = commandsResponse.data!
|
||||
const skills = skillsResponse.data!
|
||||
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
|
||||
ProviderID,
|
||||
Provider.Info
|
||||
>
|
||||
const defaultModelStarted = performance.now()
|
||||
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers)
|
||||
ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
|
||||
const modes = agents
|
||||
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
|
||||
.map((agent) => ({
|
||||
id: agent.name,
|
||||
name: agent.name,
|
||||
...(agent.description ? { description: agent.description } : {}),
|
||||
}))
|
||||
const commands = [
|
||||
...commandsData,
|
||||
...skills
|
||||
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
|
||||
.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "skill" as const,
|
||||
template: skill.content,
|
||||
hints: [],
|
||||
})),
|
||||
] as Command.Info[]
|
||||
|
||||
return Directory.build({
|
||||
directory,
|
||||
providers,
|
||||
modes,
|
||||
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
|
||||
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
...(defaultModel ? { defaultModel } : {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultModelFromSdk(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
function defaultModelFromConfig(
|
||||
configuredModel: string | undefined,
|
||||
providers: Record<ProviderID, Provider.Info>,
|
||||
): Promise<Directory.DefaultModel | undefined> {
|
||||
const configured = await sdk.config
|
||||
.get({ directory }, { throwOnError: true })
|
||||
.then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined))
|
||||
.catch(() => undefined)
|
||||
): Directory.DefaultModel | undefined {
|
||||
const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined
|
||||
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
|
||||
|
||||
const lastUsed = await lastUsedModel(sdk, directory, providers)
|
||||
if (lastUsed) return lastUsed
|
||||
|
||||
// First-session ACP startup must not scan historical sessions just to infer
|
||||
// a default. Configured model, opencode provider, then sorted best model keep
|
||||
// the protocol response deterministic without extra session/message reads.
|
||||
const opencodeProvider = providers[ProviderID.make("opencode")]
|
||||
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
|
||||
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
|
||||
@@ -588,30 +805,6 @@ async function defaultModelFromSdk(
|
||||
if (configured) return configured
|
||||
}
|
||||
|
||||
async function lastUsedModel(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
providers: Record<ProviderID, Provider.Info>,
|
||||
): Promise<Directory.DefaultModel | undefined> {
|
||||
const session = await sdk.session
|
||||
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
|
||||
.then((response) => response.data?.[0])
|
||||
.catch(() => undefined)
|
||||
if (!session) return
|
||||
|
||||
const lastUser = await sdk.session
|
||||
.messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true })
|
||||
.then((response) => response.data?.findLast((message) => message.info.role === "user")?.info)
|
||||
.catch(() => undefined)
|
||||
if (lastUser?.role !== "user") return
|
||||
if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return
|
||||
|
||||
return {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
}
|
||||
}
|
||||
|
||||
function selectDefaultModel(snapshot: Directory.Snapshot) {
|
||||
if (snapshot.defaultModel) return snapshot.defaultModel
|
||||
const model = snapshot.modelOptions[0]
|
||||
@@ -619,6 +812,43 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
|
||||
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
|
||||
}
|
||||
|
||||
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
|
||||
const text = parts
|
||||
.filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
.trim()
|
||||
if (!text.startsWith("/")) return
|
||||
|
||||
const [name, ...rest] = text.slice(1).split(/\s+/)
|
||||
if (!name) return
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
|
||||
return {
|
||||
stopReason: "end_turn",
|
||||
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
||||
...(messageId ? { userMessageId: messageId } : {}),
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
function sendUsageUpdate(
|
||||
usage: UsageService.Interface | undefined,
|
||||
sdk: OpencodeClient,
|
||||
connection: ServiceConnection | undefined,
|
||||
sessionID: string,
|
||||
directory: string,
|
||||
) {
|
||||
if (!connection) return Effect.void
|
||||
return (usage ?? makeUsageService(sdk)).sendUpdate({
|
||||
connection,
|
||||
sessionID,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
|
||||
const variants = Directory.variants(snapshot, model)
|
||||
if (!variants) return
|
||||
@@ -689,6 +919,7 @@ function registerMcpServers(
|
||||
sessionId: string,
|
||||
servers: readonly McpServer[],
|
||||
) {
|
||||
const started = performance.now()
|
||||
const current = registered.get(sessionId) ?? new Set<string>()
|
||||
registered.set(sessionId, current)
|
||||
const pending = new Set<string>()
|
||||
@@ -720,7 +951,16 @@ function registerMcpServers(
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.asVoid)
|
||||
).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() =>
|
||||
ACPNextProfile.duration("acp.mcp.register", started, {
|
||||
count: pending.size,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
|
||||
|
||||
@@ -60,6 +60,7 @@ export type PartMetadataLookupInput = {
|
||||
export type Interface = {
|
||||
readonly create: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly load: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
|
||||
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||
@@ -168,6 +169,12 @@ export const layer = Layer.effect(
|
||||
return Service.of({
|
||||
create: store,
|
||||
load: store,
|
||||
list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) {
|
||||
return [...(yield* Ref.get(sessions)).values()]
|
||||
.filter((session) => !cwd || session.cwd === cwd)
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
}),
|
||||
get,
|
||||
tryGet,
|
||||
remove,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return locationFrom(input.filePath)
|
||||
return locationFrom(input.filePath ?? input.filepath)
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ServerAuth } from "@/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ACPNextProfile } from "@/acp-next/profile"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
@@ -23,10 +24,13 @@ export const AcpCommand = effectCmd({
|
||||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
ACPNextProfile.mark("cli.acp.handler")
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
const server = yield* Effect.promise(() =>
|
||||
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
|
||||
)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
@@ -60,6 +64,7 @@ export const AcpCommand = effectCmd({
|
||||
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext })
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
import * as TuiAudio from "@tui/util/audio"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
Switch,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Show,
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import semver from "semver"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
@@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
@@ -51,7 +52,7 @@ import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import { TuiEvent } from "./event"
|
||||
import { KVProvider, useKV } from "./context/kv"
|
||||
@@ -79,8 +80,7 @@ import {
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.quick_switch.1",
|
||||
@@ -92,6 +92,10 @@ const appBindingCommands = [
|
||||
"session.quick_switch.7",
|
||||
"session.quick_switch.8",
|
||||
"session.quick_switch.9",
|
||||
] as const
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"model.list",
|
||||
"model.cycle_recent",
|
||||
"model.cycle_recent_reverse",
|
||||
@@ -111,6 +115,7 @@ const appBindingCommands = [
|
||||
"theme.mode.lock",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"workspace.list",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
@@ -123,7 +128,7 @@ const appBindingCommands = [
|
||||
"app.toggle.session_directory_filter",
|
||||
] as const
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||
export function tuiRendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||
|
||||
return {
|
||||
@@ -146,6 +151,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiRenderer(config: TuiConfig.Resolved) {
|
||||
return createCliRenderer(tuiRendererConfig(config))
|
||||
}
|
||||
|
||||
export type TuiHandle = {
|
||||
ready: Promise<void>
|
||||
done: Promise<void>
|
||||
exit: Exit
|
||||
}
|
||||
|
||||
type TuiInput = {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
renderer: CliRenderer
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
}
|
||||
|
||||
type TuiLifecycle = {
|
||||
exit: Exit
|
||||
exited: Promise<void>
|
||||
fail(error: unknown): Promise<never>
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
const formatted = FormatError(error)
|
||||
if (formatted !== undefined) return formatted
|
||||
@@ -163,105 +196,175 @@ function errorMessage(error: unknown) {
|
||||
return FormatUnknownError(error)
|
||||
}
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
win32DisableProcessedInput()
|
||||
export function tui(input: TuiInput): TuiHandle {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const onExit = async () => {
|
||||
unguard?.()
|
||||
resolve()
|
||||
}
|
||||
const onBeforeExit = async () => {
|
||||
offKeymap()
|
||||
const renderer = input.renderer
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||
const lifecycle = createTuiLifecycle({
|
||||
renderer,
|
||||
unguard,
|
||||
cleanup: async () => {
|
||||
unregisterKeymap()
|
||||
await TuiPluginRuntime.dispose()
|
||||
TuiAudio.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
|
||||
)}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
},
|
||||
})
|
||||
const ready = mountTui({ ...input, keymap, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
|
||||
const done = waitUntilDone(ready, lifecycle.exited)
|
||||
|
||||
return { ready, done, exit: lifecycle.exit }
|
||||
}
|
||||
|
||||
async function mountTui(input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; exit: Exit }) {
|
||||
const renderer = input.renderer
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
if (renderer.isDestroyed) return
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} exit={input.exit} mode={mode} />}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={input.keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider exit={input.exit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
}
|
||||
|
||||
function createTuiLifecycle(input: {
|
||||
renderer: CliRenderer
|
||||
unguard?: () => void
|
||||
cleanup: () => Promise<void>
|
||||
}): TuiLifecycle {
|
||||
let resolveExited!: () => void
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
resolveExited = resolve
|
||||
})
|
||||
let exitCompleted = false
|
||||
let exiting = false
|
||||
let cleanupTask: Promise<void> | undefined
|
||||
|
||||
const completeExit = () => {
|
||||
if (exitCompleted) return
|
||||
exitCompleted = true
|
||||
resolveExited()
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
cleanupTask ??= (async () => {
|
||||
process.off("SIGHUP", onSighup)
|
||||
try {
|
||||
await input.cleanup()
|
||||
} finally {
|
||||
input.unguard?.()
|
||||
}
|
||||
})()
|
||||
return cleanupTask
|
||||
}
|
||||
|
||||
const exit = createExit(async (reason, message) => {
|
||||
exiting = true
|
||||
await cleanup()
|
||||
if (!input.renderer.isDestroyed) {
|
||||
input.renderer.setTerminalTitle("")
|
||||
input.renderer.destroy()
|
||||
}
|
||||
win32FlushInputBuffer()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) process.stderr.write(formatted + "\n")
|
||||
}
|
||||
const text = message()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
completeExit()
|
||||
})
|
||||
const onSighup = () => {
|
||||
void exit()
|
||||
}
|
||||
|
||||
input.renderer.once("destroy", () => {
|
||||
if (exiting) return
|
||||
void cleanup().finally(() => {
|
||||
win32FlushInputBuffer()
|
||||
completeExit()
|
||||
})
|
||||
})
|
||||
process.on("SIGHUP", onSighup)
|
||||
|
||||
return {
|
||||
exit,
|
||||
exited,
|
||||
async fail(error) {
|
||||
exiting = true
|
||||
await cleanup().catch(() => {})
|
||||
if (!input.renderer.isDestroyed) input.renderer.destroy()
|
||||
completeExit()
|
||||
throw error
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
|
||||
await ready
|
||||
await exited
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
@@ -505,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workspace.list",
|
||||
title: "Manage workspaces",
|
||||
category: "Workspace",
|
||||
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "workspaces",
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
@@ -831,6 +944,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: () => {
|
||||
|
||||
@@ -67,7 +67,6 @@ export const AttachCommand = cmd({
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const config = await TuiConfig.get()
|
||||
const { tui } = await import("./app")
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
@@ -82,9 +81,12 @@ export const AttachCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
await tui({
|
||||
const { createTuiRenderer, tui } = await import("./app")
|
||||
const renderer = await createTuiRenderer(config)
|
||||
const handle = tui({
|
||||
url: args.url,
|
||||
config,
|
||||
renderer,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
@@ -93,6 +95,7 @@ export const AttachCommand = cmd({
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
await handle.done
|
||||
} finally {
|
||||
unguard?.()
|
||||
}
|
||||
|
||||
@@ -262,6 +262,13 @@ function AutoMethod(props: AutoMethodProps) {
|
||||
method: props.index,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
|
||||
? "OAuth authorization failed. Try /connect again."
|
||||
: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type WorkspaceOption = { workspace: Workspace }
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal<string>()
|
||||
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
|
||||
return project.workspace.current()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((workspace) => {
|
||||
const status = project.workspace.status(workspace.id)
|
||||
return {
|
||||
title:
|
||||
removing() === workspace.id
|
||||
? "Deleting..."
|
||||
: deleting() === workspace.id
|
||||
? `Delete ${workspace.name}? Press delete again`
|
||||
: workspace.name,
|
||||
value: { workspace },
|
||||
footer: workspace.type,
|
||||
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
|
||||
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}>●</text>,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function showDetails(workspace: Workspace) {
|
||||
setExpanded(workspace.id, (open) => !open)
|
||||
}
|
||||
|
||||
async function remove(workspace: Workspace) {
|
||||
if (removing()) return
|
||||
if (deleting() !== workspace.id) {
|
||||
setDeleting(workspace.id)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(undefined)
|
||||
setRemoving(workspace.id)
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
|
||||
error: err,
|
||||
}))
|
||||
if (result?.error) {
|
||||
setRemoving(undefined)
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (current() === workspace.id) {
|
||||
project.workspace.set(undefined)
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
setRemoving(undefined)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
void project.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
options={options()}
|
||||
onMove={(option) => {
|
||||
setDeleting(undefined)
|
||||
}}
|
||||
onSelect={(option) => showDetails(option.value.workspace)}
|
||||
actions={[
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => void remove(option.value.workspace),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,21 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import { createSignal } from "solid-js"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
export function ErrorComponent(props: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
onBeforeExit?: () => Promise<void>
|
||||
onExit: () => Promise<void>
|
||||
exit: () => Promise<void>
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = async () => {
|
||||
await props.onBeforeExit?.()
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
void handleExit()
|
||||
void props.exit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -79,7 +68,7 @@ export function ErrorComponent(props: {
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
||||
<box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -512,7 +512,10 @@ export function Prompt(props: PromptProps) {
|
||||
const content = await Editor.open({
|
||||
value,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
if (!content) return
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
|
||||
export type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
message: {
|
||||
set: (value?: string) => () => void
|
||||
clear: () => void
|
||||
@@ -10,51 +8,35 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
}
|
||||
}
|
||||
|
||||
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
const store = {
|
||||
set: (value?: string) => {
|
||||
const prev = message
|
||||
message = value
|
||||
return () => {
|
||||
message = prev
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
message = undefined
|
||||
},
|
||||
get: () => message,
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
(reason?: unknown) => {
|
||||
task ??= run(reason, store.get)
|
||||
return task
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
},
|
||||
) satisfies Exit
|
||||
}
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
const store = {
|
||||
set: (value?: string) => {
|
||||
const prev = message
|
||||
message = value
|
||||
return () => {
|
||||
message = prev
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
message = undefined
|
||||
},
|
||||
get: () => message,
|
||||
}
|
||||
const exit: Exit = Object.assign(
|
||||
(reason?: unknown) => {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
await input.onBeforeExit?.()
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
process.stderr.write(formatted + "\n")
|
||||
}
|
||||
}
|
||||
const text = store.get()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
await input.onExit?.()
|
||||
})()
|
||||
return task
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
},
|
||||
)
|
||||
process.on("SIGHUP", () => exit())
|
||||
return exit
|
||||
},
|
||||
init: (input: { exit: Exit }) => input.exit,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { getScrollAcceleration } from "@tui/util/scroll"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
@@ -133,6 +134,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
|
||||
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
|
||||
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
|
||||
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(props.api.tuiConfig))
|
||||
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
||||
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
|
||||
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
||||
@@ -713,6 +715,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
scrollAcceleration={patchScrollAcceleration()}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogAlert } from "../../ui/dialog-alert"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
@@ -133,12 +134,6 @@ const sessionBindingCommands = [
|
||||
"session.toggle.actions",
|
||||
"session.toggle.scrollbar",
|
||||
"session.toggle.generic_tool_output",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
"session.first",
|
||||
"session.last",
|
||||
"session.messages_last_user",
|
||||
@@ -153,6 +148,17 @@ const sessionBindingCommands = [
|
||||
"session.child.previous",
|
||||
] as const
|
||||
|
||||
const sessionGlobalBindingCommands = [
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
] as const
|
||||
|
||||
const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
@@ -409,15 +415,19 @@ export function Session() {
|
||||
|
||||
const local = useLocal()
|
||||
|
||||
function enterChild(sessionID: string) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
const status = sync.data.session_status[sessionID]
|
||||
if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
|
||||
}
|
||||
|
||||
function moveFirstChild() {
|
||||
if (children().length === 1) return
|
||||
const next = children().find((x) => !!x.parentID)
|
||||
if (next) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: next.id,
|
||||
})
|
||||
}
|
||||
if (next) enterChild(next.id)
|
||||
}
|
||||
|
||||
function moveChild(direction: number) {
|
||||
@@ -428,12 +438,7 @@ export function Session() {
|
||||
|
||||
if (next >= sessions.length) next = 0
|
||||
if (next < 0) next = sessions.length - 1
|
||||
if (sessions[next]) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: sessions[next].id,
|
||||
})
|
||||
}
|
||||
if (sessions[next]) enterChild(sessions[next].id)
|
||||
}
|
||||
|
||||
function childSessionHandler(func: () => void) {
|
||||
@@ -968,7 +973,10 @@ export function Session() {
|
||||
await Editor.open({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
} else {
|
||||
const exportDir = process.cwd()
|
||||
@@ -981,7 +989,10 @@ export function Session() {
|
||||
const result = await Editor.open({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
if (result !== undefined) {
|
||||
await Filesystem.write(filepath, result)
|
||||
@@ -1001,8 +1012,8 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
moveFirstChild()
|
||||
dialog.clear()
|
||||
moveFirstChild()
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1029,8 +1040,8 @@ export function Session() {
|
||||
hidden: true,
|
||||
enabled: !!session()?.parentID,
|
||||
run: childSessionHandler(() => {
|
||||
moveChild(1)
|
||||
dialog.clear()
|
||||
moveChild(1)
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -1040,8 +1051,8 @@ export function Session() {
|
||||
hidden: true,
|
||||
enabled: !!session()?.parentID,
|
||||
run: childSessionHandler(() => {
|
||||
moveChild(-1)
|
||||
dialog.clear()
|
||||
moveChild(-1)
|
||||
}),
|
||||
},
|
||||
])
|
||||
@@ -1061,6 +1072,15 @@ export function Session() {
|
||||
commands: sessionCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => renderer.currentFocusedEditor === null,
|
||||
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
||||
@@ -1752,6 +1772,7 @@ function GenericTool(props: ToolProps<any>) {
|
||||
function InlineTool(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
color?: RGBA
|
||||
complete: any
|
||||
pending: string
|
||||
spinner?: boolean
|
||||
@@ -1773,6 +1794,7 @@ function InlineTool(props: {
|
||||
})
|
||||
|
||||
const fg = createMemo(() => {
|
||||
if (props.color) return props.color
|
||||
if (permission()) return theme.warning
|
||||
if (hover() && props.onClick) return theme.text
|
||||
if (props.complete) return theme.textMuted
|
||||
@@ -2057,8 +2079,10 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
|
||||
}
|
||||
|
||||
function Task(props: ToolProps<typeof TaskTool>) {
|
||||
const { theme } = useTheme()
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
onMount(() => {
|
||||
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
|
||||
@@ -2080,6 +2104,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
)
|
||||
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
const retry = createMemo(() => {
|
||||
const status = sync.data.session_status[props.metadata.sessionId ?? ""]
|
||||
if (status?.type !== "retry") return
|
||||
return status
|
||||
})
|
||||
|
||||
const duration = createMemo(() => {
|
||||
const first = messages().find((x) => x.role === "user")?.time.created
|
||||
@@ -2094,7 +2123,10 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
|
||||
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
|
||||
|
||||
if (isRunning() && tools().length > 0) {
|
||||
const retrying = retry()
|
||||
if (isRunning() && retrying) {
|
||||
content.push(`↳ ${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
|
||||
} else if (isRunning() && tools().length > 0) {
|
||||
// content[0] += ` · ${tools().length} toolcalls`
|
||||
if (current()) {
|
||||
const state = current()!.state
|
||||
@@ -2117,6 +2149,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
return (
|
||||
<InlineTool
|
||||
icon="│"
|
||||
color={retry() ? theme.error : undefined}
|
||||
spinner={isRunning()}
|
||||
complete={props.input.description}
|
||||
pending="Delegating..."
|
||||
@@ -2125,6 +2158,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
if (props.metadata.sessionId) {
|
||||
navigate({ type: "session", sessionID: props.metadata.sessionId })
|
||||
}
|
||||
const status = retry()
|
||||
if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
|
||||
}}
|
||||
>
|
||||
{content()}
|
||||
|
||||
@@ -228,9 +228,11 @@ export const TuiThreadCommand = cmd({
|
||||
}, 1000).unref?.()
|
||||
|
||||
try {
|
||||
const { tui } = await import("./app")
|
||||
await tui({
|
||||
const { createTuiRenderer, tui } = await import("./app")
|
||||
const renderer = await createTuiRenderer(config)
|
||||
const handle = tui({
|
||||
url: transport.url,
|
||||
renderer,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
@@ -249,6 +251,7 @@ export const TuiThreadCommand = cmd({
|
||||
fork: args.fork,
|
||||
},
|
||||
})
|
||||
await handle.done
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
details?: string[]
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
@@ -167,7 +168,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (!category) return acc
|
||||
return acc + (i > 0 ? 2 : 1)
|
||||
}, 0)
|
||||
return flat().length + headers
|
||||
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -426,7 +427,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return (
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
flexDirection="column"
|
||||
position="relative"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
@@ -446,24 +447,37 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
gutter={option.gutter}
|
||||
/>
|
||||
<box
|
||||
flexDirection="row"
|
||||
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
gutter={option.gutter}
|
||||
/>
|
||||
</box>
|
||||
<For each={option.details}>
|
||||
{(detail) => (
|
||||
<box paddingLeft={3} paddingRight={3}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -90,13 +90,20 @@ export const Info = Schema.Struct({
|
||||
description: "Enable promptCacheKey for this provider (default false)",
|
||||
}),
|
||||
timeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
|
||||
}),
|
||||
headerTimeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
|
||||
}),
|
||||
chunkTimeout: Schema.optional(PositiveInt).annotate({
|
||||
description:
|
||||
|
||||
@@ -54,6 +54,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
|
||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -234,6 +234,7 @@ interface AuthResult {
|
||||
// --- Effect Service ---
|
||||
|
||||
interface State {
|
||||
config: Record<string, ConfigMCP.Info>
|
||||
status: Record<string, Status>
|
||||
clients: Record<string, MCPClient>
|
||||
defs: Record<string, MCPToolDef[]>
|
||||
@@ -525,6 +526,7 @@ export const layer = Layer.effect(
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const config = cfg.mcp ?? {}
|
||||
const s: State = {
|
||||
config: {},
|
||||
status: {},
|
||||
clients: {},
|
||||
defs: {},
|
||||
@@ -619,6 +621,10 @@ export const layer = Layer.effect(
|
||||
result[key] = s.status[key] ?? { status: "disabled" }
|
||||
}
|
||||
|
||||
for (const key of Object.keys(s.config)) {
|
||||
result[key] = s.status[key] ?? { status: "disabled" }
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -642,8 +648,9 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
|
||||
yield* createAndStore(name, mcp)
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.config[name] = mcp
|
||||
yield* createAndStore(name, mcp)
|
||||
return { status: s.status }
|
||||
})
|
||||
|
||||
@@ -677,7 +684,7 @@ export const layer = Layer.effect(
|
||||
([clientName, client]) =>
|
||||
Effect.gen(function* () {
|
||||
const mcpConfig = config[clientName]
|
||||
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined
|
||||
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : s.config[clientName]
|
||||
|
||||
const listed = s.defs[clientName]
|
||||
if (!listed) {
|
||||
@@ -756,6 +763,9 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
if (s.config[mcpName]) return s.config[mcpName]
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const mcpConfig = cfg.mcp?.[mcpName]
|
||||
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
|
||||
|
||||
@@ -3,18 +3,20 @@ import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
|
||||
const log = Log.create({ service: "plugin.digitalocean" })
|
||||
|
||||
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
|
||||
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
|
||||
const DO_API_BASE = "https://api.digitalocean.com"
|
||||
const DO_GENAI_API = `${DO_API_BASE}/v2/gen-ai`
|
||||
const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
|
||||
const OAUTH_PORT = 1456
|
||||
const OAUTH_REDIRECT_PATH = "/auth/callback"
|
||||
const OAUTH_TOKEN_PATH = "/auth/token"
|
||||
const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||
const MAK_NAME_PREFIX = "opencode-oauth"
|
||||
const OAUTH_SCOPES = "genai:read inference:query"
|
||||
|
||||
interface ImplicitTokenPayload {
|
||||
access_token: string
|
||||
@@ -28,12 +30,6 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
interface ApiKeyInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
secret_key: string
|
||||
}
|
||||
|
||||
interface RouterEntry {
|
||||
name: string
|
||||
uuid?: string
|
||||
@@ -59,7 +55,7 @@ function buildAuthorizeUrl(state: string): string {
|
||||
response_type: "token",
|
||||
client_id: DO_OAUTH_CLIENT_ID,
|
||||
redirect_uri: redirectUri(),
|
||||
scope: "genai:create genai:read",
|
||||
scope: OAUTH_SCOPES,
|
||||
state,
|
||||
})
|
||||
return `${DO_AUTHORIZE_URL}?${params.toString()}`
|
||||
@@ -91,15 +87,20 @@ const HTML_CALLBACK = `<!doctype html>
|
||||
const errorDescription = params.get("error_description") || search.get("error_description")
|
||||
const titleEl = document.getElementById("title")
|
||||
const msgEl = document.getElementById("msg")
|
||||
const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
|
||||
try {
|
||||
const body = error
|
||||
? { error, error_description: errorDescription || "" }
|
||||
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
|
||||
await fetch(${JSON.stringify(OAUTH_TOKEN_PATH)}, {
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(function () { return "" })
|
||||
throw new Error(detail || ("callback failed (" + res.status + ")"))
|
||||
}
|
||||
if (error) {
|
||||
titleEl.textContent = "Authorization Failed"
|
||||
msgEl.textContent = errorDescription || error
|
||||
@@ -225,31 +226,10 @@ function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
|
||||
})
|
||||
}
|
||||
|
||||
async function createModelAccessKey(bearer: string): Promise<ApiKeyInfo> {
|
||||
// Suffix-on-collision strategy keeps re-`/connect` non-destructive.
|
||||
const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}`
|
||||
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`)
|
||||
}
|
||||
const data = (await res.json()) as { api_key_info?: ApiKeyInfo }
|
||||
if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key")
|
||||
return data.api_key_info
|
||||
}
|
||||
|
||||
async function listRouters(
|
||||
bearer: string,
|
||||
): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
|
||||
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, {
|
||||
const res = await fetch(`${DO_GENAI_API}/models/routers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: "application/json",
|
||||
@@ -362,15 +342,16 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
|
||||
await startOAuthServer()
|
||||
const state = generateState()
|
||||
const callbackPromise = waitForOAuthCallback(state)
|
||||
const url = buildAuthorizeUrl(state)
|
||||
await open(url).catch(() => undefined)
|
||||
return {
|
||||
url: buildAuthorizeUrl(state),
|
||||
url,
|
||||
instructions:
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.",
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will use your DigitalOcean API token directly for inference and load your Inference Routers. Re-run /connect to refresh routers later.",
|
||||
method: "auto" as const,
|
||||
async callback() {
|
||||
try {
|
||||
const tokens = await callbackPromise
|
||||
const apiKeyInfo = await createModelAccessKey(tokens.access_token)
|
||||
const routerResult = await listRouters(tokens.access_token)
|
||||
const routers = routerResult.ok ? routerResult.routers : []
|
||||
if (!routerResult.ok) {
|
||||
@@ -379,12 +360,11 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
|
||||
return {
|
||||
type: "success" as const,
|
||||
provider: "digitalocean",
|
||||
key: apiKeyInfo.secret_key,
|
||||
key: tokens.access_token,
|
||||
metadata: {
|
||||
mak_uuid: apiKeyInfo.uuid,
|
||||
mak_name: apiKeyInfo.name,
|
||||
oauth_access: tokens.access_token,
|
||||
oauth_expires: String(Date.now() + tokens.expires_in * 1000),
|
||||
oauth_scopes: OAUTH_SCOPES,
|
||||
routers: JSON.stringify(
|
||||
routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
|
||||
),
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Bus } from "../bus"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { CodexAuthPlugin } from "./openai/codex"
|
||||
import { Session } from "@/session/session"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||
@@ -29,6 +29,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro
|
||||
import { registerAdapter } from "@/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "@/control-plane/types"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { InstallationChannel } from "@opencode-ai/core/installation/version"
|
||||
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
@@ -57,18 +58,28 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) {
|
||||
return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel)
|
||||
}
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CodexAuthPlugin,
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
|
||||
return [
|
||||
// Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in.
|
||||
(input) =>
|
||||
CodexAuthPlugin(input, {
|
||||
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
|
||||
}),
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
}
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
@@ -151,7 +162,7 @@ export const layer = Layer.effect(
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = yield* Effect.tryPromise({
|
||||
try: () => plugin(input),
|
||||
@@ -244,6 +255,20 @@ export const layer = Layer.effect(
|
||||
}).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(
|
||||
hooks,
|
||||
(hook) =>
|
||||
Effect.tryPromise({
|
||||
try: () => Promise.resolve(hook.dispose?.()),
|
||||
catch: (error) => {
|
||||
log.error("plugin dispose hook failed", { error })
|
||||
},
|
||||
}).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Subscribe to bus events, fiber interrupted when scope closes
|
||||
yield* (yield* bus.subscribeAll()).pipe(
|
||||
Stream.runForEach((input) =>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# OpenAI Responses WebSocket
|
||||
|
||||
Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`.
|
||||
|
||||
## Flow
|
||||
|
||||
1. A streamed `POST /responses` request arrives.
|
||||
2. If it has no `session-id` or `x-session-affinity` header, use HTTP.
|
||||
3. Title requests use HTTP.
|
||||
4. If that session's socket is busy or already in fallback mode, use HTTP.
|
||||
5. Otherwise, reuse its open socket or open a new one.
|
||||
6. Send `response.create` and return WebSocket events as SSE.
|
||||
|
||||
## Lifetime
|
||||
|
||||
- Connect timeout: 15 seconds.
|
||||
- Idle timeout: 5 minutes.
|
||||
- After a completed response, keep the socket for reuse.
|
||||
- Reuse a socket for up to 55 minutes, then replace it on the next request.
|
||||
|
||||
## Retries
|
||||
|
||||
- Retry WebSocket stream/setup failures up to 5 times, then use HTTP for that session until the pool entry is idle-pruned.
|
||||
- `websocket_connection_limit_reached` consumes the same retry budget and HTTP fallback.
|
||||
- If a WebSocket fails after its first event, fail it as retryable rather than replaying partial output in transport.
|
||||
- Abort or cancel closes the socket.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- `previous_response_id` continuation.
|
||||
- Optional second WebSocket for concurrent requests in one session. Currently these use HTTP.
|
||||
+32
-31
@@ -1,10 +1,11 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { OAUTH_DUMMY_KEY } from "../../auth"
|
||||
import os from "os"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
import { OpenAIWebSocketPool } from "./ws-pool"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -28,20 +29,12 @@ interface PkceCodes {
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(43)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||
const challenge = base64UrlEncode(hash)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||
return Array.from(bytes)
|
||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)))
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
@@ -50,10 +43,6 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
export interface IdTokenClaims {
|
||||
chatgpt_account_id?: string
|
||||
organizations?: Array<{ id: string }>
|
||||
@@ -120,6 +109,7 @@ interface TokenResponse {
|
||||
interface CodexAuthPluginOptions {
|
||||
issuer?: string
|
||||
codexApiEndpoint?: string
|
||||
experimentalWebSockets?: boolean
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||
@@ -371,8 +361,14 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
||||
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
|
||||
const issuer = options.issuer ?? ISSUER
|
||||
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
||||
let websocketFetchInstalled = false
|
||||
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
|
||||
|
||||
return {
|
||||
async dispose() {
|
||||
for (const websocketFetch of websocketFetches) websocketFetch.close()
|
||||
websocketFetches.length = 0
|
||||
},
|
||||
provider: {
|
||||
id: "openai",
|
||||
async models(provider, ctx) {
|
||||
@@ -410,7 +406,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
provider: "openai",
|
||||
async loader(getAuth) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
const websocketFetch = options.experimentalWebSockets
|
||||
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
|
||||
: undefined
|
||||
if (websocketFetch) {
|
||||
websocketFetches.push(websocketFetch)
|
||||
websocketFetchInstalled = true
|
||||
}
|
||||
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
|
||||
|
||||
let refreshPromise:
|
||||
| Promise<{
|
||||
@@ -422,7 +425,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
return {
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
// Remove dummy API key authorization header
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.delete("authorization")
|
||||
@@ -436,12 +438,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
}
|
||||
|
||||
const currentAuth = await getAuth()
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
if (currentAuth.type !== "oauth")
|
||||
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
|
||||
|
||||
// Cast to include accountId field
|
||||
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
|
||||
|
||||
// Check if token needs refresh
|
||||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||
if (!refreshPromise) {
|
||||
log.info("refreshing codex access token")
|
||||
@@ -473,7 +474,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
authWithAccount.accountId = refreshed.accountId
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers = new Headers()
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
@@ -488,16 +488,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set authorization header with access token
|
||||
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||
|
||||
// Set ChatGPT-Account-Id header for organization subscriptions
|
||||
if (authWithAccount.accountId) {
|
||||
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
|
||||
}
|
||||
|
||||
// Rewrite URL to Codex endpoint
|
||||
const parsed =
|
||||
requestInput instanceof URL
|
||||
? requestInput
|
||||
@@ -507,10 +502,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
? new URL(codexApiEndpoint)
|
||||
: parsed
|
||||
|
||||
return fetch(url, {
|
||||
const requestInit = {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
|
||||
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -521,7 +518,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
authorize: async () => {
|
||||
const { redirectUri } = await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateState()
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
||||
|
||||
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||
@@ -638,7 +635,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
if (input.model.providerID !== "openai") return
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
output.headers.session_id = input.sessionID
|
||||
output.headers["session-id"] = input.sessionID
|
||||
// Temporary fetch-layer hack: title generation currently shares the conversation
|
||||
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
|
||||
// context can be passed directly instead of smuggled through headers.
|
||||
if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true"
|
||||
},
|
||||
"chat.params": async (input, output) => {
|
||||
if (input.model.providerID !== "openai") return
|
||||
@@ -0,0 +1,272 @@
|
||||
import WebSocket from "ws"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { OpenAIWebSocket } from "./ws"
|
||||
|
||||
export const TITLE_HEADER = "x-opencode-title"
|
||||
|
||||
const log = Log.create({ service: "plugin.openai.ws" })
|
||||
|
||||
export interface CreateWebSocketFetchOptions {
|
||||
httpFetch?: typeof globalThis.fetch
|
||||
url?: string
|
||||
connectTimeout?: number
|
||||
idleTimeout?: number
|
||||
maxConnectionAge?: number
|
||||
streamRetries?: number
|
||||
}
|
||||
|
||||
interface PoolEntry {
|
||||
socket?: WebSocket
|
||||
connectedAt?: number
|
||||
lastUsedAt: number
|
||||
busy: boolean
|
||||
fallback: boolean
|
||||
streamFailures: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT = 15_000
|
||||
const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
const DEFAULT_MAX_CONNECTION_AGE = 55 * 60 * 1000
|
||||
const CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"
|
||||
|
||||
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
const httpFetch = options?.httpFetch ?? globalThis.fetch
|
||||
const pool = new Map<string, PoolEntry>()
|
||||
const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT
|
||||
const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT
|
||||
const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE
|
||||
const streamRetries = options?.streamRetries ?? 5
|
||||
const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000))
|
||||
if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") {
|
||||
pruneTimer.unref()
|
||||
}
|
||||
|
||||
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
|
||||
const internalHeaders = OpenAIWebSocket.normalizeHeaders(init?.headers)
|
||||
const httpInit = withoutInternalHeaders(init)
|
||||
|
||||
if (init?.method !== "POST" || !new URL(url).pathname.endsWith("/responses")) {
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
const body = (() => {
|
||||
try {
|
||||
if (typeof init?.body !== "string") return undefined
|
||||
const parsed = JSON.parse(init.body)
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!body?.stream) return httpFetch(input, httpInit)
|
||||
if (internalHeaders[TITLE_HEADER] === "true") {
|
||||
log.debug("http fallback", { reason: "title" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"]
|
||||
if (!sessionID) {
|
||||
log.debug("http fallback", { reason: "missing_session" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
const key = `${sessionID}:conversation`
|
||||
|
||||
const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 }
|
||||
pool.set(key, entry)
|
||||
|
||||
if (entry.fallback) {
|
||||
log.debug("http fallback", { key, reason: "fallback_active" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
if (entry.busy) {
|
||||
log.debug("http fallback", { key, reason: "busy" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
entry.busy = true
|
||||
entry.lastUsedAt = Date.now()
|
||||
try {
|
||||
entry.socket = await socket(
|
||||
entry,
|
||||
options?.url ?? url,
|
||||
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
|
||||
connectTimeout,
|
||||
maxConnectionAge,
|
||||
init?.signal,
|
||||
)
|
||||
let resolveFirstEvent: (started: boolean) => void = () => {}
|
||||
let rejectFirstEvent: (error: Error) => void = () => {}
|
||||
const firstEvent = new Promise<boolean>((resolve, reject) => {
|
||||
resolveFirstEvent = resolve
|
||||
rejectFirstEvent = reject
|
||||
})
|
||||
const response = OpenAIWebSocket.streamResponsesWebSocket({
|
||||
socket: entry.socket,
|
||||
body,
|
||||
idleTimeout,
|
||||
signal: init?.signal ?? undefined,
|
||||
onFirstEvent: () => resolveFirstEvent(true),
|
||||
onTerminal: (event) => {
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
entry.streamFailures = 0
|
||||
if (event.type !== "response.completed" && event.type !== "response.done") {
|
||||
log.warn("websocket terminal failure", { key, type: event.type })
|
||||
invalidate(entry)
|
||||
}
|
||||
},
|
||||
onConnectionInvalid: (error) => {
|
||||
log.warn("websocket invalidated", { key, error: error.message })
|
||||
entry.busy = false
|
||||
if (!entry.fallback) recordStreamFailure(entry)
|
||||
invalidate(entry)
|
||||
resolveFirstEvent(false)
|
||||
},
|
||||
onAbort: (error) => {
|
||||
log.debug("websocket aborted", { key })
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
entry.streamFailures = 0
|
||||
invalidate(entry)
|
||||
rejectFirstEvent(error)
|
||||
},
|
||||
onRetryableTerminal: async (event) => {
|
||||
const error = connectionLimitError(event)
|
||||
if (!error) return undefined
|
||||
log.warn("websocket connection limit reached", { key })
|
||||
throw error
|
||||
},
|
||||
})
|
||||
if (await firstEvent) return response
|
||||
if (!entry.fallback) return response
|
||||
log.debug("http fallback", { key, reason: "websocket_retries_exhausted" })
|
||||
return httpFetch(input, httpInit)
|
||||
} catch (error) {
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
if (OpenAIWebSocket.isAbortError(error)) {
|
||||
entry.streamFailures = 0
|
||||
invalidate(entry)
|
||||
throw error
|
||||
}
|
||||
|
||||
recordStreamFailure(entry)
|
||||
log.warn("websocket setup failed", {
|
||||
key,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
fallback: entry.fallback ? "http" : undefined,
|
||||
})
|
||||
invalidate(entry)
|
||||
if (entry.fallback) return httpFetch(input, httpInit)
|
||||
return failedResponse(
|
||||
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
|
||||
cause: error,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function recordStreamFailure(entry: PoolEntry) {
|
||||
entry.streamFailures++
|
||||
// Codex counts retries after the initial failed WebSocket attempt.
|
||||
if (entry.streamFailures > streamRetries) entry.fallback = true
|
||||
}
|
||||
|
||||
function prune() {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of pool) {
|
||||
if (entry.busy) continue
|
||||
if (now - entry.lastUsedAt < idleTimeout) continue
|
||||
log.debug("websocket idle prune", { key })
|
||||
invalidate(entry)
|
||||
pool.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
log.debug("websocket pool close", { count: pool.size })
|
||||
clearInterval(pruneTimer)
|
||||
for (const entry of pool.values()) invalidate(entry)
|
||||
pool.clear()
|
||||
}
|
||||
|
||||
return Object.assign(websocketFetch, { close })
|
||||
}
|
||||
|
||||
function connectionLimitError(event: Record<string, unknown>) {
|
||||
if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return
|
||||
return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE)
|
||||
}
|
||||
|
||||
function failedResponse(error: ProviderError.ResponseStreamError) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(error)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function socket(
|
||||
entry: PoolEntry,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
connectTimeout: number,
|
||||
maxConnectionAge: number,
|
||||
signal?: AbortSignal | null,
|
||||
) {
|
||||
if (
|
||||
entry.socket?.readyState === WebSocket.OPEN &&
|
||||
entry.connectedAt &&
|
||||
Date.now() - entry.connectedAt < maxConnectionAge
|
||||
) {
|
||||
return entry.socket
|
||||
}
|
||||
|
||||
invalidate(entry)
|
||||
const next = await OpenAIWebSocket.connectResponsesWebSocket({
|
||||
url: OpenAIWebSocket.toWebSocketUrl(url),
|
||||
headers,
|
||||
timeout: connectTimeout,
|
||||
signal: signal ?? undefined,
|
||||
})
|
||||
entry.connectedAt = Date.now()
|
||||
return next
|
||||
}
|
||||
|
||||
function invalidate(entry: PoolEntry) {
|
||||
if (entry.socket) {
|
||||
entry.socket.on("error", () => {})
|
||||
entry.socket.terminate()
|
||||
entry.socket = undefined
|
||||
}
|
||||
entry.connectedAt = undefined
|
||||
}
|
||||
|
||||
export function withoutInternalHeaders<T extends { headers?: HeadersInit }>(init: T | undefined): T | undefined {
|
||||
if (!init?.headers) return init
|
||||
if (init.headers instanceof Headers) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.delete(TITLE_HEADER)
|
||||
return { ...init, headers }
|
||||
}
|
||||
|
||||
if (Array.isArray(init.headers)) {
|
||||
return { ...init, headers: init.headers.filter((item) => item[0].toLowerCase() !== TITLE_HEADER) }
|
||||
}
|
||||
|
||||
return {
|
||||
...init,
|
||||
headers: Object.fromEntries(Object.entries(init.headers).filter(([key]) => key.toLowerCase() !== TITLE_HEADER)),
|
||||
}
|
||||
}
|
||||
|
||||
export * as OpenAIWebSocketPool from "./ws-pool"
|
||||
@@ -0,0 +1,326 @@
|
||||
// Low-level OpenAI Responses WebSocket protocol helpers. Session pooling,
|
||||
// fallback, and continuation state intentionally live above this file.
|
||||
|
||||
import WebSocket from "ws"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
|
||||
export const PROTOCOL_HEADER = "responses_websockets=2026-02-06"
|
||||
|
||||
export interface ConnectResponsesWebSocketOptions {
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface StreamResponsesWebSocketOptions {
|
||||
socket: WebSocket
|
||||
body: Record<string, unknown>
|
||||
idleTimeout?: number
|
||||
signal?: AbortSignal
|
||||
onFirstEvent?: () => void
|
||||
onComplete?: (event: Record<string, unknown>) => void
|
||||
onTerminal?: (event: Record<string, unknown>) => void
|
||||
onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>
|
||||
onConnectionInvalid?: (error: ProviderError.ResponseStreamError) => void
|
||||
onAbort?: (error: Error) => void
|
||||
}
|
||||
|
||||
export function toWebSocketUrl(url: string) {
|
||||
return url.replace(/^http/, "ws")
|
||||
}
|
||||
|
||||
export function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!headers) return result
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
result[key.toLowerCase()] = value
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
result[key.toLowerCase()] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value != null) result[key.toLowerCase()] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): error is DOMException {
|
||||
return error instanceof DOMException && error.name === "AbortError"
|
||||
}
|
||||
|
||||
export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions) {
|
||||
return new Promise<WebSocket>((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(abortError(options.signal))
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers,
|
||||
"openai-beta": options.headers["openai-beta"] ?? PROTOCOL_HEADER,
|
||||
}
|
||||
delete headers["content-length"]
|
||||
|
||||
const socket = new WebSocket(options.url, { headers })
|
||||
const timeout = options.timeout
|
||||
? setTimeout(() => {
|
||||
cleanup()
|
||||
socket.on("error", () => {})
|
||||
socket.terminate()
|
||||
reject(new Error("WebSocket connect timed out"))
|
||||
}, options.timeout)
|
||||
: undefined
|
||||
|
||||
function cleanup() {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
socket.off("open", onOpen)
|
||||
socket.off("error", onError)
|
||||
socket.off("close", onClose)
|
||||
options.signal?.removeEventListener("abort", onAbort)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
cleanup()
|
||||
resolve(socket)
|
||||
}
|
||||
|
||||
function onError(error: Error) {
|
||||
socket.on("error", () => {})
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
function onClose(code: number, reason: Buffer) {
|
||||
cleanup()
|
||||
reject(new Error(closeMessage("WebSocket closed before open", code, reason)))
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
cleanup()
|
||||
socket.on("error", () => {})
|
||||
socket.terminate()
|
||||
reject(abortError(options.signal))
|
||||
}
|
||||
|
||||
socket.once("open", onOpen)
|
||||
socket.once("error", onError)
|
||||
socket.once("close", onClose)
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
let socket = options.socket
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let cleanupSocket = () => {}
|
||||
let completed = false
|
||||
let emitted = false
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function cleanup() {
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
cleanupSocket()
|
||||
options.signal?.removeEventListener("abort", onAbort)
|
||||
}
|
||||
|
||||
function terminateSocket(target = socket) {
|
||||
target.on("error", () => {})
|
||||
target.terminate()
|
||||
}
|
||||
|
||||
function closeCompleted() {
|
||||
cleanup()
|
||||
controller?.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
controller?.close()
|
||||
}
|
||||
|
||||
function invalidate(error: ProviderError.ResponseStreamError) {
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
options.onConnectionInvalid?.(error)
|
||||
controller?.error(error)
|
||||
}
|
||||
|
||||
function resetIdleTimeout(message: string) {
|
||||
if (completed) return
|
||||
if (!options.idleTimeout) return
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(() => invalidate(new ProviderError.ResponseStreamError(message)), options.idleTimeout)
|
||||
}
|
||||
|
||||
async function onMessage(data: WebSocket.RawData, isBinary: boolean) {
|
||||
if (completed) return
|
||||
if (isBinary) {
|
||||
invalidate(new ProviderError.ResponseStreamError("Unexpected binary WebSocket frame"))
|
||||
return
|
||||
}
|
||||
|
||||
const text = data.toString()
|
||||
const event = (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
if (event?.type === "error" && !emitted && options.onRetryableTerminal) {
|
||||
cleanupSocket()
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = undefined
|
||||
try {
|
||||
const next = await options.onRetryableTerminal(event)
|
||||
if (completed) {
|
||||
if (next) terminateSocket(next)
|
||||
return
|
||||
}
|
||||
if (next) {
|
||||
attach(next)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
invalidate(
|
||||
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
|
||||
cause: error,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!emitted) options.onFirstEvent?.()
|
||||
controller?.enqueue(
|
||||
encoder.encode(
|
||||
`${text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => `data: ${line}`)
|
||||
.join("\n")}\n\n`,
|
||||
),
|
||||
)
|
||||
emitted = true
|
||||
resetIdleTimeout("idle timeout waiting for websocket")
|
||||
|
||||
if (!event) return
|
||||
|
||||
if (event.type === "response.completed" || event.type === "response.done") {
|
||||
completed = true
|
||||
options.onComplete?.(event)
|
||||
options.onTerminal?.(event)
|
||||
closeCompleted()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "response.failed" || event.type === "response.incomplete" || event.type === "error") {
|
||||
completed = true
|
||||
options.onTerminal?.(event)
|
||||
closeCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
function onError(error: Error) {
|
||||
invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
|
||||
}
|
||||
|
||||
function onClose(code: number, reason: Buffer) {
|
||||
if (completed) return
|
||||
invalidate(
|
||||
new ProviderError.ResponseStreamError(closeMessage("WebSocket closed before response.completed", code, reason)),
|
||||
)
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
const error = abortError(options.signal)
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
terminateSocket()
|
||||
options.onAbort?.(error)
|
||||
controller?.error(error)
|
||||
}
|
||||
|
||||
function onCancel(reason: unknown) {
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
terminateSocket()
|
||||
options.onAbort?.(cancelError(reason))
|
||||
}
|
||||
|
||||
function attach(next: WebSocket) {
|
||||
cleanupSocket()
|
||||
socket = next
|
||||
socket.on("message", onMessage)
|
||||
socket.once("error", onError)
|
||||
socket.once("close", onClose)
|
||||
cleanupSocket = () => {
|
||||
socket.off("message", onMessage)
|
||||
socket.off("error", onError)
|
||||
socket.off("close", onClose)
|
||||
}
|
||||
const { stream: _stream, background: _background, ...payload } = options.body
|
||||
resetIdleTimeout("idle timeout sending websocket request")
|
||||
socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => {
|
||||
if (completed) return
|
||||
resetIdleTimeout("idle timeout waiting for websocket")
|
||||
if (error) invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(next) {
|
||||
controller = next
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
|
||||
attach(socket)
|
||||
},
|
||||
cancel(reason) {
|
||||
onCancel(reason)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function cancelError(reason: unknown) {
|
||||
if (isAbortError(reason)) return reason
|
||||
if (reason instanceof Error) return reason
|
||||
return new DOMException(typeof reason === "string" ? reason : "Aborted", "AbortError")
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal | undefined) {
|
||||
const reason = signal?.reason
|
||||
if (isAbortError(reason)) return reason
|
||||
return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError")
|
||||
}
|
||||
|
||||
function closeMessage(message: string, code: number, reason: Buffer) {
|
||||
const details = [`code ${code}`]
|
||||
if (code === 1009) details.push("message too big")
|
||||
if (reason.length > 0) details.push(reason.toString())
|
||||
return `${message} (${details.join(": ")})`
|
||||
}
|
||||
|
||||
export * as OpenAIWebSocket from "./ws"
|
||||
@@ -3,6 +3,22 @@ import { STATUS_CODES } from "http"
|
||||
import { iife } from "@/util/iife"
|
||||
import type { ProviderID } from "./schema"
|
||||
|
||||
export class HeaderTimeoutError extends Error {
|
||||
public override readonly name = "ProviderHeaderTimeoutError"
|
||||
|
||||
constructor(public readonly ms: number) {
|
||||
super(`Provider response headers timed out after ${ms}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseStreamError extends Error {
|
||||
public override readonly name = "ProviderResponseStreamError"
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from overflow detection patterns in:
|
||||
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
|
||||
const OVERFLOW_PATTERNS = [
|
||||
|
||||
@@ -28,9 +28,10 @@ import * as ProviderTransform from "./transform"
|
||||
import { ModelID, ProviderID } from "./schema"
|
||||
import { ModelStatus } from "./model-status"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderError } from "./error"
|
||||
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
|
||||
function shouldUseCopilotResponsesApi(modelID: string): boolean {
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
@@ -85,6 +86,15 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function timeoutController(ms: number) {
|
||||
const ctl = new AbortController()
|
||||
const id = setTimeout(() => ctl.abort(new ProviderError.HeaderTimeoutError(ms)), ms)
|
||||
return {
|
||||
signal: ctl.signal,
|
||||
clear: () => clearTimeout(id),
|
||||
}
|
||||
}
|
||||
|
||||
function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
|
||||
if (!project) return
|
||||
if (location !== "eu" && location !== "us") return
|
||||
@@ -194,7 +204,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
return sdk.responses(modelID)
|
||||
},
|
||||
options: {},
|
||||
options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT },
|
||||
}),
|
||||
xai: () =>
|
||||
Effect.succeed({
|
||||
@@ -1601,16 +1611,21 @@ export const layer = Layer.effect(
|
||||
|
||||
const customFetch = options["fetch"]
|
||||
const chunkTimeout = options["chunkTimeout"]
|
||||
const headerTimeout = options["headerTimeout"]
|
||||
delete options["chunkTimeout"]
|
||||
delete options["headerTimeout"]
|
||||
|
||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||
const fetchFn = customFetch ?? fetch
|
||||
const opts = init ?? {}
|
||||
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
||||
const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout
|
||||
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
|
||||
const signals: AbortSignal[] = []
|
||||
|
||||
if (opts.signal) signals.push(opts.signal)
|
||||
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
|
||||
if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal)
|
||||
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
|
||||
signals.push(AbortSignal.timeout(options["timeout"]))
|
||||
|
||||
@@ -1639,7 +1654,7 @@ export const layer = Layer.effect(
|
||||
...opts,
|
||||
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
|
||||
timeout: false,
|
||||
})
|
||||
}).finally(() => headerTimeoutCtl?.clear())
|
||||
|
||||
if (!chunkAbortCtl) return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
|
||||
@@ -14,18 +14,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
|
||||
For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type.
|
||||
|
||||
Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks.
|
||||
Use `HttpApiBuilder.group(...)` with `handleRaw(...)` for declared endpoints that need the raw request or response, including WebSocket upgrade routes. This keeps endpoint middleware, routing context, and OpenAPI metadata on one typed route tree.
|
||||
|
||||
```ts
|
||||
export const rawRoute = HttpRouter.use((router) =>
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
|
||||
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
|
||||
return handlers.handleRaw("connect", (ctx) => connectPty(ctx.request, pty))
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use raw `HttpRouter.use(...)` only for routes outside the declared API surface, such as a catch-all UI fallback.
|
||||
|
||||
Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request.
|
||||
|
||||
Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services.
|
||||
@@ -34,4 +36,4 @@ Use `Effect.provideService(...)` in middleware only for request-derived context,
|
||||
|
||||
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
|
||||
|
||||
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
|
||||
When adding middleware, declare endpoint-contract middleware on the owning `HttpApiGroup` and provide its implementation layer at the assembly boundary in `server.ts`. Keep router middleware for truly raw fallback routes or global transport policy.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
|
||||
export const EventPaths = {
|
||||
event: "/event",
|
||||
@@ -20,5 +22,8 @@ export const EventApi = HttpApi.make("event").add(
|
||||
}),
|
||||
),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization)
|
||||
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { Authorization, PtyConnectAuthorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
@@ -138,9 +139,10 @@ export const PtyApi = HttpApi.make("pty")
|
||||
export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
HttpApiGroup.make("pty-connect")
|
||||
.add(
|
||||
// Decode PTY connection query fields in the raw handler after checking
|
||||
// existence, preserving the established empty-404 response ordering.
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Connected session"),
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -149,8 +151,22 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
summary: "Connect to PTY session",
|
||||
description:
|
||||
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
|
||||
transform: (operation) => ({
|
||||
...operation,
|
||||
parameters: [
|
||||
...(operation.parameters ?? []),
|
||||
...["directory", "workspace", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
|
||||
in: "query",
|
||||
name,
|
||||
schema: { type: "string" },
|
||||
})),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(PtyConnectAuthorization),
|
||||
)
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import * as ApiError from "../errors"
|
||||
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
|
||||
import { CursorQuery, PtyConnectApi } from "../groups/pty"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
|
||||
@@ -121,37 +121,39 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
yield* router.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
Effect.gen(function* () {
|
||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||
const exists = yield* pty.get(params.ptyID).pipe(
|
||||
|
||||
return handlers.handleRaw(
|
||||
"connect",
|
||||
Effect.fn("PtyHttpApi.connect")(function* (ctx: {
|
||||
params: { ptyID: PtyID }
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const exists = yield* pty.get(ctx.params.ptyID).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
const query = Schema.decodeUnknownOption(CursorQuery)(yield* HttpServerRequest.ParsedSearchParams)
|
||||
if (Option.isNone(query)) return HttpServerResponse.empty({ status: 400 })
|
||||
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
const valid = validOrigin(request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
const valid = validOrigin(ctx.request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
: false
|
||||
if (!valid) return HttpServerResponse.empty({ status: 403 })
|
||||
}
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
|
||||
? parsedCursor
|
||||
: undefined
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const socket = yield* Effect.orDie(ctx.request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
@@ -186,7 +188,7 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
},
|
||||
}
|
||||
const handler = yield* pty
|
||||
.connect(params.ptyID, adapter, cursor)
|
||||
.connect(ctx.params.ptyID, adapter, cursor)
|
||||
.pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
@@ -194,12 +196,8 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
// No `pending[]`-style early-frame buffer (the legacy handler had one).
|
||||
// `request.upgrade` returns a Socket without running the WS handshake; the
|
||||
// handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves
|
||||
// and the message callback is registered. The client therefore can't fire
|
||||
// `open` and start sending until the listener is already wired. Don't move
|
||||
// `runRaw` ahead of `pty.connect` without re-introducing a buffer.
|
||||
// The handshake runs inside `socket.runRaw`, after the input callback is
|
||||
// registered, so the client cannot send frames before PTY input is wired.
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
.pipe(
|
||||
|
||||
@@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
|
||||
},
|
||||
) {}
|
||||
|
||||
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
|
||||
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
|
||||
{
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
},
|
||||
) {}
|
||||
|
||||
function emptyCredential() {
|
||||
return {
|
||||
username: "",
|
||||
@@ -105,7 +112,6 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
|
||||
if (hasPtyConnectTicketURL(url)) return yield* effect
|
||||
return yield* credentialFromURL(url, request).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
@@ -129,6 +135,24 @@ export const authorizationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectAuthorizationLayer = Layer.effect(
|
||||
PtyConnectAuthorization,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuth.Config
|
||||
if (!ServerAuth.required(config)) return PtyConnectAuthorization.of((effect) => effect)
|
||||
return PtyConnectAuthorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (hasPtyConnectTicketURL(url)) return yield* effect
|
||||
return yield* credentialFromURL(url, request).pipe(
|
||||
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const v2AuthorizationLayer = Layer.effect(
|
||||
V2Authorization,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { WorkspaceRouteContext } from "./workspace-routing"
|
||||
|
||||
@@ -41,10 +41,3 @@ export const instanceContextLayer = Layer.effect(
|
||||
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
|
||||
}),
|
||||
)
|
||||
|
||||
export const instanceRouterMiddleware = HttpRouter.middleware()(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
return (effect) => provideInstanceContext(effect, store)
|
||||
}),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user