mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f43f2b354c | |||
| c2ba0bef6b | |||
| 83b47693c3 | |||
| 1ca86ad973 | |||
| 7afe537c69 | |||
| e566c082f1 | |||
| bc93da4a46 | |||
| 4194d55522 | |||
| f0d1029719 | |||
| b274db88b0 | |||
| 9b7b402737 | |||
| 5e6370363b | |||
| dd6020656e | |||
| 8c758e443b | |||
| 0df6aed6ca | |||
| aa05fd23b3 | |||
| fd09760903 | |||
| 3a4772abb4 | |||
| 2cf65c025a | |||
| f3f1204802 | |||
| b3aea1c39d | |||
| 79a493e7f5 | |||
| 952d4d446f | |||
| 0b84e24e65 | |||
| 3776975d5c | |||
| d2c99ba97c | |||
| 6f3a3600b9 | |||
| 9ca650f97c | |||
| db3b54a30d | |||
| b4f769f695 | |||
| e5ef00b8b8 | |||
| 917d6449e3 | |||
| db31c42e39 | |||
| c79ced174e | |||
| 8ba8af1dd9 | |||
| 6e82f5d3b9 | |||
| 48d1a6e5b9 | |||
| bc47030d4d | |||
| e6d20440f9 | |||
| c9cbd2b1f4 | |||
| 292dfa3036 | |||
| 3bb0d7fda0 | |||
| 8977881e09 | |||
| e6c9b6bef7 | |||
| 2092350cfa | |||
| 5fb0d7c99c | |||
| a1b4843a33 | |||
| cc7827fe08 | |||
| 76e4d88d21 | |||
| 439ed66c7b | |||
| 047d434aa2 | |||
| d7651519f3 | |||
| 1eb3a43add | |||
| dcae95e2bb | |||
| 0cc507b8a9 | |||
| 727beae2d5 | |||
| cd64a17e37 | |||
| d35ca49c31 | |||
| 5cf97f1b96 | |||
| 65c6a71903 | |||
| a779027303 | |||
| 20fa444f31 | |||
| c66d84169a | |||
| 7dbe8c4c13 | |||
| 8864b01d0b | |||
| ec95b27308 | |||
| be2f74d44a | |||
| 912a801060 | |||
| 0215498f63 | |||
| 1539bd6794 | |||
| 939da6a5e7 | |||
| 0f27ee7b4c | |||
| 51cef27579 | |||
| a0c77b71ae | |||
| e62918224f | |||
| 30d140008f | |||
| a38d44265b | |||
| 0cce215de4 | |||
| a15ec425de | |||
| ae494ffce5 | |||
| 25c2150baf | |||
| 9314992f98 | |||
| 0fbe489e35 | |||
| 03e9789064 | |||
| 0c558a6856 | |||
| f297bd3b8b | |||
| 1bfc23f503 | |||
| 3839aafa25 | |||
| bd2f37bc90 | |||
| 120e4e7388 | |||
| 6f91bc7415 | |||
| c46f6ae112 | |||
| 285444aab4 |
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
|
- v2
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate:
|
generate:
|
||||||
|
|||||||
@@ -251,4 +251,3 @@ opencode-drive stop --name demo
|
|||||||
```bash
|
```bash
|
||||||
opencode-drive dir --name demo
|
opencode-drive dir --name demo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -64,7 +64,6 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
|
||||||
"@opencode-ai/session-ui": "workspace:*",
|
"@opencode-ai/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -112,6 +111,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"happy-dom": "20.11.1",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
@@ -395,6 +395,7 @@
|
|||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
|
"mime-types": "3.0.2",
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
@@ -489,18 +490,6 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
|
||||||
"version": "1.18.8",
|
|
||||||
"dependencies": {
|
|
||||||
"effect": "catalog:",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tsconfig/bun": "catalog:",
|
|
||||||
"@types/node": "catalog:",
|
|
||||||
"@typescript/native-preview": "catalog:",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.8",
|
"version": "1.18.8",
|
||||||
@@ -582,6 +571,20 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/merman": {
|
||||||
|
"name": "@opencode-ai/merman",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opentui/core": "catalog:",
|
||||||
|
"string-width": "catalog:",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.8",
|
"version": "1.18.8",
|
||||||
@@ -690,6 +693,7 @@
|
|||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"modal": "0.9.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
@@ -892,6 +896,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/merman": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
@@ -1523,6 +1528,18 @@
|
|||||||
|
|
||||||
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
|
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
|
||||||
|
|
||||||
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
||||||
|
|
||||||
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
||||||
@@ -1731,6 +1748,10 @@
|
|||||||
|
|
||||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||||
|
|
||||||
|
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
|
||||||
|
|
||||||
|
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||||
|
|
||||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
||||||
@@ -1825,6 +1846,8 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||||
|
|
||||||
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
||||||
|
|
||||||
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
|
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
|
||||||
@@ -2067,8 +2090,6 @@
|
|||||||
|
|
||||||
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
|
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
|
||||||
|
|
||||||
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
|
|
||||||
|
|
||||||
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
|
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
|
||||||
|
|
||||||
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
|
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
|
||||||
@@ -2077,6 +2098,8 @@
|
|||||||
|
|
||||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||||
|
|
||||||
|
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||||
|
|
||||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||||
|
|
||||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||||
@@ -3311,6 +3334,8 @@
|
|||||||
|
|
||||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|
||||||
|
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
|
||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
@@ -3551,6 +3576,10 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||||
|
|
||||||
|
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
|
||||||
|
|
||||||
|
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
|
||||||
|
|
||||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||||
|
|
||||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||||
@@ -4583,6 +4612,8 @@
|
|||||||
|
|
||||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||||
|
|
||||||
|
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||||
|
|
||||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||||
|
|
||||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||||
@@ -4809,6 +4840,8 @@
|
|||||||
|
|
||||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||||
|
|
||||||
|
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
|
||||||
|
|
||||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||||
|
|
||||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||||
@@ -4847,6 +4880,10 @@
|
|||||||
|
|
||||||
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
||||||
|
|
||||||
|
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
|
||||||
|
|
||||||
|
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
|
||||||
|
|
||||||
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
|
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
|
||||||
|
|
||||||
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
||||||
@@ -5645,6 +5682,8 @@
|
|||||||
|
|
||||||
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
||||||
|
|
||||||
|
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
|
||||||
|
|
||||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||||
|
|
||||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||||
@@ -6333,8 +6372,6 @@
|
|||||||
|
|
||||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||||
|
|
||||||
"@opencode-ai/app/@opencode-ai/sdk": ["@opencode-ai/sdk@vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
|
|
||||||
|
|
||||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
||||||
@@ -6605,6 +6642,8 @@
|
|||||||
|
|
||||||
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||||
|
|
||||||
|
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||||
|
|
||||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
@@ -6749,6 +6788,8 @@
|
|||||||
|
|
||||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
|
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||||
|
|
||||||
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||||
|
|
||||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||||
|
|||||||
@@ -5,8 +5,27 @@
|
|||||||
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
||||||
- Remove migrations that exist only on the V2 branch.
|
- Remove migrations that exist only on the V2 branch.
|
||||||
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
||||||
- Add explicit data operations to that migration where generated DDL is insufficient.
|
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
|
||||||
- Test the migration against a populated database at the exact `dev` schema.
|
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
|
||||||
|
- Show committed session progress while the endpoint runs.
|
||||||
|
|
||||||
|
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
|
||||||
|
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
|
||||||
|
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
|
||||||
|
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
|
||||||
|
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
|
||||||
|
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
|
||||||
|
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
|
||||||
|
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
|
||||||
|
and help flows do not trigger the backfill.
|
||||||
|
|
||||||
|
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
|
||||||
|
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
|
||||||
|
the status check and spinner presentation.
|
||||||
|
|
||||||
|
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
|
||||||
|
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
|
||||||
|
for the current single elected server process.
|
||||||
|
|
||||||
## Preserve
|
## Preserve
|
||||||
|
|
||||||
@@ -15,20 +34,35 @@ The canonical V1 data remains in its existing tables. In particular, preserve `s
|
|||||||
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
||||||
workspace relationships.
|
workspace relationships.
|
||||||
|
|
||||||
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
|
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
|
||||||
generated migration must not drop the table.
|
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
|
||||||
|
ID, model ID, and variant, normalizing an absent variant to `default`.
|
||||||
|
|
||||||
## Truncate
|
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
|
||||||
|
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
|
||||||
|
cache-write token totals with those sums.
|
||||||
|
|
||||||
Truncate these pre-launch V2 tables before applying schema changes:
|
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
|
||||||
|
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
|
||||||
|
parts, and file history.
|
||||||
|
|
||||||
- `event`
|
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
|
||||||
- `event_sequence`
|
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
|
||||||
- `session_message`
|
|
||||||
|
|
||||||
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
|
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
|
||||||
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
|
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
|
||||||
and `part` rows rather than retaining its pre-launch V2 contents.
|
unmanaged legacy storage.
|
||||||
|
|
||||||
|
## Per-Session Replacement
|
||||||
|
|
||||||
|
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
|
||||||
|
hold SQLite's writer lock long enough to block the running TUI.
|
||||||
|
|
||||||
|
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
|
||||||
|
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
|
||||||
|
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
|
||||||
|
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
|
||||||
|
remain untouched.
|
||||||
|
|
||||||
## Message Backfill
|
## Message Backfill
|
||||||
|
|
||||||
@@ -36,9 +70,23 @@ Backfill canonical V1 history from `message` and `part` into `session_message`.
|
|||||||
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
||||||
V2 session APIs, which read `session_message`.
|
V2 session APIs, which read `session_message`.
|
||||||
|
|
||||||
|
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
|
||||||
|
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
|
||||||
|
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
|
||||||
|
untouched in the V1 tables.
|
||||||
|
|
||||||
|
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
|
||||||
|
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
|
||||||
|
leave skipped source rows unchanged.
|
||||||
|
|
||||||
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
||||||
avoid rewriting other persisted state that may refer to a message.
|
avoid rewriting other persisted state that may refer to a message.
|
||||||
|
|
||||||
|
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
|
||||||
|
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
|
||||||
|
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
|
||||||
|
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
|
||||||
|
|
||||||
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
||||||
contiguous `session_message.seq` values starting at `0`.
|
contiguous `session_message.seq` values starting at `0`.
|
||||||
|
|
||||||
@@ -46,15 +94,122 @@ Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becom
|
|||||||
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
||||||
payload.
|
payload.
|
||||||
|
|
||||||
|
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
|
||||||
|
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
|
||||||
|
stable. Omit only explicitly dropped internal concepts and undecodable messages.
|
||||||
|
|
||||||
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
||||||
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
||||||
part mappings must be decided explicitly before implementing the backfill.
|
part mappings must be decided explicitly before implementing the backfill.
|
||||||
|
|
||||||
|
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
|
||||||
|
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
|
||||||
|
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
|
||||||
|
Keep all source rows unchanged in the V1 `message` and `part` tables.
|
||||||
|
|
||||||
|
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
|
||||||
|
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
|
||||||
|
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
|
||||||
|
|
||||||
|
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
|
||||||
|
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
|
||||||
|
state has no start time. Set the error to type `tool.interrupted` with message
|
||||||
|
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
|
||||||
|
|
||||||
|
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
|
||||||
|
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
|
||||||
|
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
|
||||||
|
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
|
||||||
|
`[Old tool result content cleared]` as the only output and omit attachments.
|
||||||
|
|
||||||
|
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
|
||||||
|
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
|
||||||
|
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
|
||||||
|
|
||||||
|
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
|
||||||
|
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
|
||||||
|
`tokens.total` because it is derivable and V2 does not persist it.
|
||||||
|
|
||||||
|
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
|
||||||
|
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
|
||||||
|
|
||||||
|
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
|
||||||
|
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
|
||||||
|
|
||||||
|
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
|
||||||
|
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
|
||||||
|
`message` row.
|
||||||
|
|
||||||
|
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
|
||||||
|
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
|
||||||
|
finish values in metadata.
|
||||||
|
|
||||||
|
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
|
||||||
|
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
|
||||||
|
preserve the message, and discard V1-only retryability and raw provider details.
|
||||||
|
|
||||||
|
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
|
||||||
|
useful enough to preserve. The original retry rows remain in the V1 `part` table.
|
||||||
|
|
||||||
|
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
|
||||||
|
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
|
||||||
|
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
|
||||||
|
markers without snapshots.
|
||||||
|
|
||||||
|
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
|
||||||
|
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
|
||||||
|
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
|
||||||
|
removed.
|
||||||
|
|
||||||
|
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
|
||||||
|
a blocker for the V1 migration, which should target the current storage contract.
|
||||||
|
|
||||||
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
||||||
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
||||||
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
||||||
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
||||||
|
|
||||||
|
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
|
||||||
|
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
|
||||||
|
|
||||||
|
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
|
||||||
|
metadata. The original value remains in the V1 `message` row.
|
||||||
|
|
||||||
|
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
|
||||||
|
must not affect future V2 execution. The original value remains in the V1 `message` row.
|
||||||
|
|
||||||
|
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
|
||||||
|
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
|
||||||
|
format only in the V1 `message` row.
|
||||||
|
|
||||||
|
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
|
||||||
|
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
|
||||||
|
row.
|
||||||
|
|
||||||
|
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
|
||||||
|
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
|
||||||
|
Omit `agents` when there are no agent parts.
|
||||||
|
|
||||||
|
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
|
||||||
|
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
|
||||||
|
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
|
||||||
|
|
||||||
|
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
|
||||||
|
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
|
||||||
|
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
|
||||||
|
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
|
||||||
|
|
||||||
|
For a non-embedded V1 file, do not create a V2 file attachment. Append
|
||||||
|
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
|
||||||
|
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
|
||||||
|
the preserved V1 `part` row.
|
||||||
|
|
||||||
|
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
|
||||||
|
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
|
||||||
|
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
|
||||||
|
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
|
||||||
|
source user row. Entirely synthetic messages continue to reuse their original message ID.
|
||||||
|
|
||||||
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
||||||
admitted compaction input ID and preserves references to the initiating message.
|
admitted compaction input ID and preserves references to the initiating message.
|
||||||
|
|
||||||
@@ -64,9 +219,13 @@ serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an
|
|||||||
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
||||||
assistant row.
|
assistant row.
|
||||||
|
|
||||||
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
|
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
|
||||||
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
|
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
|
||||||
before migrated history. The `event` table remains empty.
|
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
|
||||||
|
|
||||||
|
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
|
||||||
|
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
|
||||||
|
history. The migrated session's prior `event` rows are removed in the same transaction.
|
||||||
|
|
||||||
## Drop
|
## Drop
|
||||||
|
|
||||||
@@ -74,6 +233,7 @@ Drop these pre-launch V2 tables without preserving or transforming their rows:
|
|||||||
|
|
||||||
- `session_input`
|
- `session_input`
|
||||||
- `session_context_epoch`
|
- `session_context_epoch`
|
||||||
|
- `data_migration`
|
||||||
|
|
||||||
Do not transfer `session_input` rows into `session_pending`.
|
Do not transfer `session_input` rows into `session_pending`.
|
||||||
|
|
||||||
@@ -103,16 +263,36 @@ schema.
|
|||||||
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
|
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
|
||||||
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
|
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
|
||||||
|
|
||||||
## Verification
|
## Execution
|
||||||
|
|
||||||
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
|
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
|
||||||
credentials, permissions, shares, and workspaces. After migration, it should verify:
|
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
|
||||||
|
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
|
||||||
|
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
|
||||||
|
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
|
||||||
|
|
||||||
- Preserved rows and encoded values remain unchanged.
|
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
|
||||||
- Todo rows remain available in the unchanged `todo` table.
|
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
|
||||||
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
|
|
||||||
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
|
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
|
||||||
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
|
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
|
||||||
- Dropped tables no longer exist.
|
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
|
||||||
- New tables exist and are empty.
|
exists.
|
||||||
- The final schema has no ungenerated changes.
|
|
||||||
|
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
|
||||||
|
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
|
||||||
|
seed migration state specially.
|
||||||
|
|
||||||
|
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
|
||||||
|
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
|
||||||
|
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
|
||||||
|
cursor. Mark the migration complete after the final session and return immediately on later calls.
|
||||||
|
|
||||||
|
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
|
||||||
|
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
|
||||||
|
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
|
||||||
|
successfully committed session advances the cursor.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Detailed migration test design is deferred until after the canonical migration is implemented.
|
||||||
|
|||||||
@@ -368,11 +368,12 @@ Other provider exports listed above remain direct facades until they explicitly
|
|||||||
|
|
||||||
## Provider options & HTTP overlays
|
## Provider options & HTTP overlays
|
||||||
|
|
||||||
Three escape hatches in order of stability:
|
Request options in order of stability:
|
||||||
|
|
||||||
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
|
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
|
||||||
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
|
2. **`promptCacheKey`** — stable cache affinity lowered by every protocol that supports it.
|
||||||
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
|
3. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `store`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
|
||||||
|
4. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
|
||||||
|
|
||||||
Route/provider defaults are overridden by request-level values for each axis.
|
Route/provider defaults are overridden by request-level values for each axis.
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ const model = OpenAI.configure({
|
|||||||
//
|
//
|
||||||
// - `generation`: common controls such as max tokens, temperature, topP/topK,
|
// - `generation`: common controls such as max tokens, temperature, topP/topK,
|
||||||
// penalties, seed, and stop sequences.
|
// penalties, seed, and stop sequences.
|
||||||
|
// - `promptCacheKey`: stable cache affinity for protocols that support it.
|
||||||
// - `providerOptions`: namespaced provider-native behavior. For example,
|
// - `providerOptions`: namespaced provider-native behavior. For example,
|
||||||
// OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking
|
// OpenAI store behavior, Anthropic thinking, Gemini thinking config, or
|
||||||
// config, or OpenRouter routing/reasoning.
|
// OpenRouter routing/reasoning.
|
||||||
// - `http`: last-resort serializable overlays for final request body, headers,
|
// - `http`: last-resort serializable overlays for final request body, headers,
|
||||||
// and query params. Prefer typed `providerOptions` when a field is stable.
|
// and query params. Prefer typed `providerOptions` when a field is stable.
|
||||||
//
|
//
|
||||||
@@ -45,9 +46,7 @@ const request = LLM.request({
|
|||||||
system: "You are concise and practical.",
|
system: "You are concise and practical.",
|
||||||
prompt: "Tell me a joke",
|
prompt: "Tell me a joke",
|
||||||
generation: { maxTokens: 80, temperature: 0.7 },
|
generation: { maxTokens: 80, temperature: 0.7 },
|
||||||
providerOptions: {
|
promptCacheKey: "tutorial-joke",
|
||||||
openai: { promptCacheKey: "tutorial-joke" },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. `generate` sends the request and collects the event stream into one
|
// 3. `generate` sends the request and collects the event stream into one
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ const countHints = (request: LLMRequest) =>
|
|||||||
|
|
||||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
|
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto"))
|
||||||
|
return request
|
||||||
const policy = resolve(request.cache)
|
const policy = resolve(request.cache)
|
||||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,20 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
|
|||||||
|
|
||||||
const ADAPTER = "gemini"
|
const ADAPTER = "gemini"
|
||||||
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
||||||
|
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
|
||||||
|
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
||||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
|
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
|
||||||
|
// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
|
||||||
|
// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
|
||||||
|
const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||||
|
if (!/(^|\/)gemini-/i.test(modelID)) return false
|
||||||
|
if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
|
||||||
|
if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
|
||||||
|
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
||||||
|
}
|
||||||
|
|
||||||
export interface OptionsInput {
|
export interface OptionsInput {
|
||||||
readonly [key: string]: unknown
|
readonly [key: string]: unknown
|
||||||
readonly cachedContent?: string
|
readonly cachedContent?: string
|
||||||
@@ -145,6 +157,9 @@ const GeminiGenerationConfig = Schema.Struct({
|
|||||||
temperature: Schema.optional(Schema.Number),
|
temperature: Schema.optional(Schema.Number),
|
||||||
topP: Schema.optional(Schema.Number),
|
topP: Schema.optional(Schema.Number),
|
||||||
topK: Schema.optional(Schema.Number),
|
topK: Schema.optional(Schema.Number),
|
||||||
|
frequencyPenalty: Schema.optional(Schema.Number),
|
||||||
|
presencePenalty: Schema.optional(Schema.Number),
|
||||||
|
seed: Schema.optional(Schema.Number),
|
||||||
stopSequences: optionalArray(Schema.String),
|
stopSequences: optionalArray(Schema.String),
|
||||||
thinkingConfig: Schema.optional(GeminiThinkingConfig),
|
thinkingConfig: Schema.optional(GeminiThinkingConfig),
|
||||||
})
|
})
|
||||||
@@ -202,11 +217,13 @@ interface ParserState {
|
|||||||
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
|
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
|
||||||
//
|
//
|
||||||
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
|
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
|
||||||
// drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
|
// drop empty root parameter schemas while preserving nested empty objects,
|
||||||
// coerce `const` to `[const]` enum, recurse properties/items, propagate
|
// expand type arrays into `anyOf`, derive `nullable: true` from null members,
|
||||||
|
// coerce `const` to `[const]` enum, recurse properties/items, and propagate
|
||||||
// only an allowlisted set of keys (description, required, format, type,
|
// only an allowlisted set of keys (description, required, format, type,
|
||||||
// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
|
// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength).
|
||||||
// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
|
// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is
|
||||||
|
// silently dropped.
|
||||||
//
|
//
|
||||||
// Sanitize runs first, then project. The implementation lives in
|
// Sanitize runs first, then project. The implementation lives in
|
||||||
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
|
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
|
||||||
@@ -282,6 +299,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
|
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
|
||||||
|
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
|
||||||
|
let hasSignedToolCall = false
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||||
@@ -294,7 +313,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
parts.push(lowerToolCall(part))
|
const lowered = lowerToolCall(part)
|
||||||
|
const signature = lowered.thoughtSignature
|
||||||
|
parts.push({
|
||||||
|
...lowered,
|
||||||
|
thoughtSignature:
|
||||||
|
signature ??
|
||||||
|
(requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
|
||||||
|
? SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
||||||
|
: undefined),
|
||||||
|
})
|
||||||
|
if (signature !== undefined) hasSignedToolCall = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,6 +417,9 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
|||||||
temperature: generation?.temperature,
|
temperature: generation?.temperature,
|
||||||
topP: generation?.topP,
|
topP: generation?.topP,
|
||||||
topK: generation?.topK,
|
topK: generation?.topK,
|
||||||
|
frequencyPenalty: generation?.frequencyPenalty,
|
||||||
|
presencePenalty: generation?.presencePenalty,
|
||||||
|
seed: generation?.seed,
|
||||||
stopSequences: generation?.stop,
|
stopSequences: generation?.stop,
|
||||||
thinkingConfig: options.thinkingConfig,
|
thinkingConfig: options.thinkingConfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ const lowerOptions = (request: LLMRequest) => {
|
|||||||
return {
|
return {
|
||||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||||
...(options.store !== undefined ? { store: options.store } : {}),
|
...(options.store !== undefined ? { store: options.store } : {}),
|
||||||
...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
|
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||||
...(options.include ? { include: options.include } : {}),
|
...(options.include ? { include: options.include } : {}),
|
||||||
...(options.reasoningEffort || options.reasoningSummary
|
...(options.reasoningEffort || options.reasoningSummary
|
||||||
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
|
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||||
|
|
||||||
|
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
|
||||||
|
// extension until direct Google OpenAI-compatible routing is supported here:
|
||||||
|
// https://github.com/vercel/ai/issues/11590
|
||||||
|
// https://github.com/vercel/ai/pull/11745
|
||||||
|
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
|
||||||
|
|
||||||
const OpenAIChatUserContent = Schema.Union([
|
const OpenAIChatUserContent = Schema.Union([
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("text"),
|
type: Schema.Literal("text"),
|
||||||
@@ -126,6 +132,7 @@ export const bodyFields = {
|
|||||||
stream: Schema.Literal(true),
|
stream: Schema.Literal(true),
|
||||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||||
store: Schema.optional(Schema.Boolean),
|
store: Schema.optional(Schema.Boolean),
|
||||||
|
prompt_cache_key: Schema.optional(Schema.String),
|
||||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||||
max_completion_tokens: Schema.optional(Schema.Number),
|
max_completion_tokens: Schema.optional(Schema.Number),
|
||||||
max_tokens: Schema.optional(Schema.Number),
|
max_tokens: Schema.optional(Schema.Number),
|
||||||
@@ -145,22 +152,33 @@ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
|
|||||||
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
|
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
|
||||||
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into
|
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into
|
||||||
// this provider-native event shape.
|
// this provider-native event shape.
|
||||||
const OpenAIChatUsage = Schema.Struct({
|
const OpenAIChatUsage = Schema.StructWithRest(
|
||||||
prompt_tokens: Schema.optional(Schema.Number),
|
Schema.Struct({
|
||||||
completion_tokens: Schema.optional(Schema.Number),
|
prompt_tokens: optionalNull(Schema.Number),
|
||||||
total_tokens: Schema.optional(Schema.Number),
|
completion_tokens: optionalNull(Schema.Number),
|
||||||
prompt_tokens_details: optionalNull(
|
total_tokens: optionalNull(Schema.Number),
|
||||||
Schema.Struct({
|
prompt_tokens_details: optionalNull(
|
||||||
cached_tokens: Schema.optional(Schema.Number),
|
Schema.StructWithRest(
|
||||||
cache_write_tokens: Schema.optional(Schema.Number),
|
Schema.Struct({
|
||||||
}),
|
cached_tokens: optionalNull(Schema.Number),
|
||||||
),
|
cache_write_tokens: optionalNull(Schema.Number),
|
||||||
completion_tokens_details: optionalNull(
|
}),
|
||||||
Schema.Struct({
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
reasoning_tokens: Schema.optional(Schema.Number),
|
),
|
||||||
}),
|
),
|
||||||
),
|
completion_tokens_details: optionalNull(
|
||||||
})
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
reasoning_tokens: optionalNull(Schema.Number),
|
||||||
|
accepted_prediction_tokens: optionalNull(Schema.Number),
|
||||||
|
rejected_prediction_tokens: optionalNull(Schema.Number),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
|
||||||
const OpenAIChatToolCallDeltaFunction = Schema.Struct({
|
const OpenAIChatToolCallDeltaFunction = Schema.Struct({
|
||||||
name: optionalNull(Schema.String),
|
name: optionalNull(Schema.String),
|
||||||
@@ -168,7 +186,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const OpenAIChatToolCallDelta = Schema.Struct({
|
const OpenAIChatToolCallDelta = Schema.Struct({
|
||||||
index: Schema.Number,
|
index: optionalNull(Schema.Number),
|
||||||
id: optionalNull(Schema.String),
|
id: optionalNull(Schema.String),
|
||||||
function: optionalNull(OpenAIChatToolCallDeltaFunction),
|
function: optionalNull(OpenAIChatToolCallDeltaFunction),
|
||||||
})
|
})
|
||||||
@@ -222,6 +240,8 @@ export interface ParserState {
|
|||||||
readonly reasoningDetails: Array<unknown>
|
readonly reasoningDetails: Array<unknown>
|
||||||
readonly reasoningDetailsObserved: boolean
|
readonly reasoningDetailsObserved: boolean
|
||||||
readonly reasoningEmitted: boolean
|
readonly reasoningEmitted: boolean
|
||||||
|
readonly latestToolIndex?: number
|
||||||
|
readonly nextToolIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -351,12 +371,13 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
|||||||
return text
|
return text
|
||||||
})()
|
})()
|
||||||
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||||
|
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
|
||||||
const result = {
|
const result = {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
|
||||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||||
reasoning_details: details,
|
...(details !== undefined ? { reasoning_details: details } : {}),
|
||||||
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
|
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
|
||||||
}
|
}
|
||||||
if (field === undefined || reasoningText === undefined) return result
|
if (field === undefined || reasoningText === undefined) return result
|
||||||
return { ...result, [field]: reasoningText }
|
return { ...result, [field]: reasoningText }
|
||||||
@@ -490,6 +511,7 @@ const lowerOptions = (request: LLMRequest) => {
|
|||||||
const options = OpenAIOptions.resolve(request)
|
const options = OpenAIOptions.resolve(request)
|
||||||
return {
|
return {
|
||||||
...(options.store !== undefined ? { store: options.store } : {}),
|
...(options.store !== undefined ? { store: options.store } : {}),
|
||||||
|
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -559,22 +581,34 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
// satisfied on both sides.
|
// satisfied on both sides.
|
||||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const cached = usage.prompt_tokens_details?.cached_tokens
|
const input = usage.prompt_tokens ?? undefined
|
||||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
|
const output = usage.completion_tokens ?? undefined
|
||||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||||
|
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||||
|
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens: usage.prompt_tokens,
|
inputTokens: input,
|
||||||
outputTokens: usage.completion_tokens,
|
outputTokens: output,
|
||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: cached,
|
cacheReadInputTokens: cached,
|
||||||
cacheWriteInputTokens: cacheWrite,
|
cacheWriteInputTokens: cacheWrite,
|
||||||
reasoningTokens: reasoning,
|
reasoningTokens: reasoning,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||||
providerMetadata: { openai: usage },
|
providerMetadata: { openai: usage },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolIndexByID = (
|
||||||
|
tools: ParserState["tools"],
|
||||||
|
pendingTools: ParserState["pendingTools"],
|
||||||
|
id: string | undefined,
|
||||||
|
) => {
|
||||||
|
if (!id) return undefined
|
||||||
|
const entry = Object.entries({ ...pendingTools, ...tools }).find(([, tool]) => tool?.id === id)
|
||||||
|
return entry ? Number(entry[0]) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const reasoningDelta = (
|
const reasoningDelta = (
|
||||||
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
|
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
|
||||||
configuredField?: string,
|
configuredField?: string,
|
||||||
@@ -657,25 +691,39 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const usage = mapUsage(event.usage) ?? state.usage
|
const usage = mapUsage(event.usage) ?? state.usage
|
||||||
const choice = event.choices?.[0]
|
const choice = event.choices?.[0]
|
||||||
const finishReason = choice?.finish_reason
|
const rawFinishReason = choice?.finish_reason
|
||||||
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
|
const finishReason =
|
||||||
: state.finishReason
|
rawFinishReason !== undefined && rawFinishReason !== null
|
||||||
|
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
|
||||||
|
: state.finishReason
|
||||||
const delta = choice?.delta
|
const delta = choice?.delta
|
||||||
const toolDeltas = delta?.tool_calls ?? []
|
const toolDeltas = delta?.tool_calls ?? []
|
||||||
let tools = state.tools
|
let tools = state.tools
|
||||||
let pendingTools = state.pendingTools
|
let pendingTools = state.pendingTools
|
||||||
|
let latestToolIndex = state.latestToolIndex
|
||||||
|
let nextToolIndex = state.nextToolIndex
|
||||||
|
|
||||||
let lifecycle = state.lifecycle
|
let lifecycle = state.lifecycle
|
||||||
|
|
||||||
const reasoning = reasoningDelta(delta, state.reasoningField)
|
const reasoning = reasoningDelta(delta, state.reasoningField)
|
||||||
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
const hasLateContent =
|
||||||
|
Boolean(delta?.content) ||
|
||||||
|
reasoning !== undefined ||
|
||||||
|
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||||
|
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
||||||
|
if (state.finishReason !== undefined) {
|
||||||
|
if (hasLateContent)
|
||||||
|
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
||||||
|
return [{ ...state, usage }, events] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasoningField = state.reasoningField ?? reasoning?.field
|
||||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||||
const deltaMetadata = reasoningMetadata(reasoningField)
|
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||||
if (!state.lifecycle.text.has("text-0") && text !== undefined)
|
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
|
||||||
else if (
|
else if (
|
||||||
reasoningDetailsObserved &&
|
reasoningDetailsObserved &&
|
||||||
!lifecycle.reasoning.has("reasoning-0") &&
|
!lifecycle.reasoning.has("reasoning-0") &&
|
||||||
@@ -694,24 +742,36 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tool of toolDeltas) {
|
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||||
const current = tools[tool.index]
|
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||||
const pending = pendingTools[tool.index]
|
for (const [position, tool] of toolDeltas.entries()) {
|
||||||
|
const matched = toolIndexByID(tools, pendingTools, tool.id || undefined)
|
||||||
|
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
|
||||||
|
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
|
||||||
|
const index =
|
||||||
|
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
||||||
|
const current = tools[index]
|
||||||
|
const pending = pendingTools[index]
|
||||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||||
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
|
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
|
||||||
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
|
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
|
||||||
|
latestToolIndex = index
|
||||||
|
nextToolIndex = Math.max(nextToolIndex, index + 1)
|
||||||
if (!current && (!id || !name)) {
|
if (!current && (!id || !name)) {
|
||||||
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
|
pendingTools = {
|
||||||
|
...pendingTools,
|
||||||
|
[index]: { id: id || undefined, name: name || undefined, input: text },
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pendingTools = { ...pendingTools }
|
pendingTools = { ...pendingTools }
|
||||||
delete pendingTools[tool.index]
|
delete pendingTools[index]
|
||||||
}
|
}
|
||||||
const result = ToolStream.appendOrStart(
|
const result = ToolStream.appendOrStart(
|
||||||
ADAPTER,
|
ADAPTER,
|
||||||
tools,
|
tools,
|
||||||
tool.index,
|
index,
|
||||||
{ id: id || undefined, name: name || undefined, text },
|
{ id: id || undefined, name: name || undefined, text },
|
||||||
"OpenAI Chat tool call delta is missing id or name",
|
"OpenAI Chat tool call delta is missing id or name",
|
||||||
)
|
)
|
||||||
@@ -743,6 +803,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
reasoningDetails: state.reasoningDetails,
|
reasoningDetails: state.reasoningDetails,
|
||||||
reasoningDetailsObserved,
|
reasoningDetailsObserved,
|
||||||
reasoningEmitted,
|
reasoningEmitted,
|
||||||
|
latestToolIndex,
|
||||||
|
nextToolIndex,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
] as const
|
] as const
|
||||||
@@ -750,14 +812,18 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
|
|
||||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const hasToolCalls = state.toolCallEvents.length > 0
|
const toolCallEvents =
|
||||||
|
state.finishReason === undefined && Object.keys(state.tools).length > 0
|
||||||
|
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
|
||||||
|
: state.toolCallEvents
|
||||||
|
const hasToolCalls = toolCallEvents.length > 0
|
||||||
const reason = state.finishReason
|
const reason = state.finishReason
|
||||||
? {
|
? {
|
||||||
...state.finishReason,
|
...state.finishReason,
|
||||||
normalized:
|
normalized:
|
||||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||||
}
|
}
|
||||||
: undefined
|
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
|
||||||
const metadata = reasoningMetadata(
|
const metadata = reasoningMetadata(
|
||||||
state.reasoningField,
|
state.reasoningField,
|
||||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||||
@@ -767,9 +833,9 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
|||||||
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||||
: state.lifecycle
|
: state.lifecycle
|
||||||
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||||
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||||
events.push(...state.toolCallEvents)
|
events.push(...toolCallEvents)
|
||||||
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,6 +865,7 @@ export const protocol = Protocol.make({
|
|||||||
reasoningDetails: [],
|
reasoningDetails: [],
|
||||||
reasoningDetailsObserved: false,
|
reasoningDetailsObserved: false,
|
||||||
reasoningEmitted: false,
|
reasoningEmitted: false,
|
||||||
|
nextToolIndex: 0,
|
||||||
}),
|
}),
|
||||||
step,
|
step,
|
||||||
onHalt: finishEvents,
|
onHalt: finishEvents,
|
||||||
|
|||||||
@@ -61,37 +61,57 @@ const emptyObjectSchema = (schema: Record<string, unknown>) =>
|
|||||||
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
|
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
|
||||||
!schema.additionalProperties
|
!schema.additionalProperties
|
||||||
|
|
||||||
const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
|
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
|
||||||
if (!isRecord(schema)) return undefined
|
if (!isRecord(schema)) return undefined
|
||||||
if (emptyObjectSchema(schema)) return undefined
|
if (!nested && emptyObjectSchema(schema)) return undefined
|
||||||
return Object.fromEntries(
|
const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
|
||||||
|
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined
|
||||||
|
const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false
|
||||||
|
const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf
|
||||||
|
const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined
|
||||||
|
const result = Object.fromEntries(
|
||||||
[
|
[
|
||||||
["description", schema.description],
|
["description", schema.description],
|
||||||
["required", schema.required],
|
["required", schema.required],
|
||||||
["format", schema.format],
|
["format", schema.format],
|
||||||
["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
|
["type", types ? (types.length === 0 ? "null" : undefined) : schema.type],
|
||||||
["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
|
[
|
||||||
|
"nullable",
|
||||||
|
(Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
],
|
||||||
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
|
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
|
||||||
[
|
[
|
||||||
"properties",
|
"properties",
|
||||||
isRecord(schema.properties)
|
isRecord(schema.properties)
|
||||||
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)]))
|
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)]))
|
||||||
: undefined,
|
: undefined,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"items",
|
"items",
|
||||||
Array.isArray(schema.items)
|
Array.isArray(schema.items)
|
||||||
? schema.items.map(projectNode)
|
? schema.items.map((item) => projectNode(item, true))
|
||||||
: schema.items === undefined
|
: schema.items === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: projectNode(schema.items),
|
: projectNode(schema.items, true),
|
||||||
],
|
],
|
||||||
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined],
|
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined],
|
||||||
["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined],
|
[
|
||||||
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined],
|
"anyOf",
|
||||||
|
anyOfTypes
|
||||||
|
? hasNullAnyOf && anyOfTypes.length === 1
|
||||||
|
? undefined
|
||||||
|
: anyOfTypes.map((item) => projectNode(item, true))
|
||||||
|
: types && types.length > 0
|
||||||
|
? types.map((type) => ({ type }))
|
||||||
|
: undefined,
|
||||||
|
],
|
||||||
|
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
|
||||||
["minLength", schema.minLength],
|
["minLength", schema.minLength],
|
||||||
].filter((entry) => entry[1] !== undefined),
|
].filter((entry) => entry[1] !== undefined),
|
||||||
)
|
)
|
||||||
|
return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
|
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
|||||||
export interface Resolved {
|
export interface Resolved {
|
||||||
readonly instructions?: string
|
readonly instructions?: string
|
||||||
readonly store?: boolean
|
readonly store?: boolean
|
||||||
readonly promptCacheKey?: string
|
|
||||||
readonly reasoningEffort?: string
|
readonly reasoningEffort?: string
|
||||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||||
@@ -50,7 +49,6 @@ export const resolve = (request: LLMRequest): Resolved => {
|
|||||||
return {
|
return {
|
||||||
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
|
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
|
||||||
store: typeof input?.store === "boolean" ? input.store : undefined,
|
store: typeof input?.store === "boolean" ? input.store : undefined,
|
||||||
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
|
|
||||||
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
|
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
|
||||||
reasoningSummary:
|
reasoningSummary:
|
||||||
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
|
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ProviderPackage } from "../provider-package"
|
|||||||
import { ProviderID, type ModelID } from "../schema"
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat"
|
import * as OpenAIChat from "../protocols/openai-chat"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
|
import { ProviderShared } from "../protocols/shared"
|
||||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
|
||||||
export const id = ProviderID.make("azure")
|
export const id = ProviderID.make("azure")
|
||||||
@@ -19,7 +20,7 @@ export type LanguageModelOptions = AzureURL &
|
|||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly apiVersion?: string
|
readonly apiVersion?: string
|
||||||
readonly queryParams?: Record<string, string>
|
readonly queryParams?: Record<string, string>
|
||||||
readonly useCompletionUrls?: boolean
|
readonly useDeploymentBasedUrls?: boolean
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
export type Config = LanguageModelOptions
|
export type Config = LanguageModelOptions
|
||||||
@@ -29,27 +30,22 @@ export type Settings = ProviderPackage.Settings &
|
|||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly apiVersion?: string
|
readonly apiVersion?: string
|
||||||
readonly queryParams?: Readonly<Record<string, string>>
|
readonly queryParams?: Readonly<Record<string, string>>
|
||||||
|
readonly useDeploymentBasedUrls?: boolean
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
|
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||||
|
|
||||||
const responsesRoute = OpenAIResponses.route.with({
|
const responsesRoute = OpenAIResponses.route.with({
|
||||||
id: "azure-openai-responses",
|
id: "azure-openai-responses",
|
||||||
provider: id,
|
provider: id,
|
||||||
auth: routeAuth,
|
auth: routeAuth,
|
||||||
endpoint: {
|
|
||||||
query: { "api-version": "v1" },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const chatRoute = OpenAIChat.route.with({
|
const chatRoute = OpenAIChat.route.with({
|
||||||
id: "azure-openai-chat",
|
id: "azure-openai-chat",
|
||||||
provider: id,
|
provider: id,
|
||||||
auth: routeAuth,
|
auth: routeAuth,
|
||||||
endpoint: {
|
|
||||||
query: { "api-version": "v1" },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const routes = [responsesRoute, chatRoute]
|
export const routes = [responsesRoute, chatRoute]
|
||||||
@@ -59,7 +55,7 @@ const defaults = (input: Config) => {
|
|||||||
apiKey: _,
|
apiKey: _,
|
||||||
apiVersion: _apiVersion,
|
apiVersion: _apiVersion,
|
||||||
resourceName: _resourceName,
|
resourceName: _resourceName,
|
||||||
useCompletionUrls: _useCompletionUrls,
|
useDeploymentBasedUrls: _useDeploymentBasedUrls,
|
||||||
baseURL: _baseURL,
|
baseURL: _baseURL,
|
||||||
queryParams: _queryParams,
|
queryParams: _queryParams,
|
||||||
...rest
|
...rest
|
||||||
@@ -80,37 +76,39 @@ const auth = (input: Config) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
|
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
|
||||||
route.with({
|
route.with({
|
||||||
auth: auth(input),
|
auth: auth(input),
|
||||||
endpoint: {
|
endpoint: endpoint(input, modelID),
|
||||||
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
|
|
||||||
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
|
|
||||||
query: {
|
|
||||||
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
|
|
||||||
...input.queryParams,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function endpoint(input: Config, modelID: string | ModelID) {
|
||||||
|
const baseURL = ProviderShared.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!))
|
||||||
|
const query = { "api-version": input.apiVersion ?? "v1", ...input.queryParams }
|
||||||
|
|
||||||
|
if (input.useDeploymentBasedUrls) return { baseURL: `${baseURL}/deployments/${modelID}`, query }
|
||||||
|
if (input.baseURL !== undefined && !new URL(input.baseURL).hostname.endsWith(".openai.azure.com")) {
|
||||||
|
return { baseURL, query: input.queryParams }
|
||||||
|
}
|
||||||
|
return { baseURL: `${baseURL}/v1`, query }
|
||||||
|
}
|
||||||
|
|
||||||
export const configure = (input: Config) => {
|
export const configure = (input: Config) => {
|
||||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
|
||||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
|
||||||
const modelDefaults = defaults(input)
|
const modelDefaults = defaults(input)
|
||||||
|
|
||||||
const responses = (modelID: string | ModelID) =>
|
const responses = (modelID: string | ModelID) =>
|
||||||
configuredResponsesRoute
|
configuredRoute(responsesRoute, input, modelID)
|
||||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
|
|
||||||
const chat = (modelID: string | ModelID) =>
|
const chat = (modelID: string | ModelID) =>
|
||||||
configuredChatRoute
|
configuredRoute(chatRoute, input, modelID)
|
||||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
|
model: responses,
|
||||||
responses,
|
responses,
|
||||||
chat,
|
chat,
|
||||||
configure,
|
configure,
|
||||||
@@ -131,6 +129,7 @@ const config = (settings: Settings): Config => {
|
|||||||
limits: settings.limits,
|
limits: settings.limits,
|
||||||
providerOptions: settings.providerOptions,
|
providerOptions: settings.providerOptions,
|
||||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||||
|
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||||
}
|
}
|
||||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
|||||||
|
|
||||||
const VERSION = "vertex-2023-10-16" as const
|
const VERSION = "vertex-2023-10-16" as const
|
||||||
|
|
||||||
// models.dev uses this provider id even though the API contract is Anthropic Messages.
|
export const id = ProviderID.make("google-vertex")
|
||||||
export const id = ProviderID.make("google-vertex-anthropic")
|
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput &
|
export type Config = RouteDefaultsInput &
|
||||||
GoogleVertexShared.OAuthOptions & {
|
GoogleVertexShared.OAuthOptions & {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export interface OpenResponsesOptionsInput {
|
|||||||
readonly [key: string]: unknown
|
readonly [key: string]: unknown
|
||||||
readonly instructions?: string
|
readonly instructions?: string
|
||||||
readonly store?: boolean
|
readonly store?: boolean
|
||||||
readonly promptCacheKey?: string
|
|
||||||
readonly reasoningEffort?: ReasoningEffort
|
readonly reasoningEffort?: ReasoningEffort
|
||||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
|
|||||||
const openai = Object.fromEntries(
|
const openai = Object.fromEntries(
|
||||||
definedEntries({
|
definedEntries({
|
||||||
store: options?.store,
|
store: options?.store,
|
||||||
promptCacheKey: options?.promptCacheKey,
|
|
||||||
reasoningEffort: options?.reasoningEffort,
|
reasoningEffort: options?.reasoningEffort,
|
||||||
reasoningSummary: options?.reasoningSummary,
|
reasoningSummary: options?.reasoningSummary,
|
||||||
include: options?.include,
|
include: options?.include,
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export interface OpenRouterOptions {
|
|||||||
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
|
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
|
||||||
readonly models?: ReadonlyArray<string>
|
readonly models?: ReadonlyArray<string>
|
||||||
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
|
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
|
||||||
readonly promptCacheKey?: string
|
|
||||||
readonly provider?: OpenRouterProviderRouting
|
readonly provider?: OpenRouterProviderRouting
|
||||||
readonly reasoning?: Readonly<{
|
readonly reasoning?: Readonly<{
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
@@ -122,6 +121,7 @@ export const protocol = Protocol.make({
|
|||||||
...body,
|
...body,
|
||||||
messages,
|
messages,
|
||||||
...bodyOptions(request.providerOptions?.openrouter),
|
...bodyOptions(request.providerOptions?.openrouter),
|
||||||
|
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||||
} as OpenRouterBody
|
} as OpenRouterBody
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -161,7 +161,6 @@ const bodyOptions = (input: unknown) => {
|
|||||||
...(isRecord(debug) ? { debug } : {}),
|
...(isRecord(debug) ? { debug } : {}),
|
||||||
...(typeof user === "string" ? { user } : {}),
|
...(typeof user === "string" ? { user } : {}),
|
||||||
...(isRecord(reasoning) ? { reasoning } : {}),
|
...(isRecord(reasoning) ? { reasoning } : {}),
|
||||||
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const chatRoute = Route.make({
|
|||||||
protocol: OpenAIChat.protocol,
|
protocol: OpenAIChat.protocol,
|
||||||
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||||
transport: OpenAICompatibleChat.route.transport,
|
transport: OpenAICompatibleChat.route.transport,
|
||||||
|
headers: ({ request }): Record<string, string> =>
|
||||||
|
request.promptCacheKey ? { "x-grok-conv-id": request.promptCacheKey } : {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const routes = [responsesRoute, chatRoute]
|
export const routes = [responsesRoute, chatRoute]
|
||||||
|
|||||||
@@ -255,13 +255,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
|
|||||||
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
||||||
return Effect.succeed(event)
|
return Effect.succeed(event)
|
||||||
}),
|
}),
|
||||||
Stream.onEnd(
|
Stream.onEnd(Effect.suspend(() => (terminal ? Effect.void : Effect.fail(incompleteStreamError(route))))),
|
||||||
Effect.suspend(() =>
|
|
||||||
terminal
|
|
||||||
? Effect.void
|
|
||||||
: Effect.fail(incompleteStreamError(route)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Context, Effect, Layer } from "effect"
|
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
Headers,
|
Headers,
|
||||||
@@ -198,8 +198,20 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient
|
|||||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||||
|
Schema.fromJsonString(
|
||||||
|
Schema.Struct({
|
||||||
|
message: Schema.optionalKey(Schema.String),
|
||||||
|
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
||||||
if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
|
if (body.body && body.body.length <= 500) {
|
||||||
|
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
|
||||||
|
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
|
||||||
|
}
|
||||||
return `Provider request failed with HTTP ${status}`
|
return `Provider request failed with HTTP ${status}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,35 +45,34 @@ const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
|||||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||||
"value" in value
|
"value" in value
|
||||||
|
|
||||||
export const ToolResultValue = Object.assign(
|
const toolResultValueSchema = Schema.Union([
|
||||||
Schema.Union([
|
Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.Literal("json"),
|
||||||
type: Schema.Literal("json"),
|
value: Schema.Unknown,
|
||||||
value: Schema.Unknown,
|
}),
|
||||||
}),
|
Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.Literal("text"),
|
||||||
type: Schema.Literal("text"),
|
value: Schema.Unknown,
|
||||||
value: Schema.Unknown,
|
}),
|
||||||
}),
|
Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.Literal("error"),
|
||||||
type: Schema.Literal("error"),
|
value: Schema.Unknown,
|
||||||
value: Schema.Unknown,
|
}),
|
||||||
}),
|
Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.Literal("content"),
|
||||||
type: Schema.Literal("content"),
|
value: Schema.Array(Tool.Content),
|
||||||
value: Schema.Array(Tool.Content),
|
}),
|
||||||
}),
|
]).annotate({ identifier: "LLM.ToolResult" })
|
||||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
|
||||||
{
|
|
||||||
is: isToolResultValue,
|
export const ToolResultValue = Object.assign(toolResultValueSchema, {
|
||||||
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
|
is: isToolResultValue,
|
||||||
if (isToolResultValue(value)) return value
|
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
|
||||||
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
|
if (isToolResultValue(value)) return value
|
||||||
return { type, value }
|
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
|
||||||
},
|
return { type, value }
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
|
|
||||||
|
|
||||||
export interface ToolOutput {
|
export interface ToolOutput {
|
||||||
readonly structured: unknown
|
readonly structured: unknown
|
||||||
@@ -272,6 +271,8 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
|||||||
providerOptions: Schema.optional(ProviderOptions),
|
providerOptions: Schema.optional(ProviderOptions),
|
||||||
http: Schema.optional(HttpOptions),
|
http: Schema.optional(HttpOptions),
|
||||||
cache: Schema.optional(CachePolicy),
|
cache: Schema.optional(CachePolicy),
|
||||||
|
// Stable cache affinity for protocols that support provider-managed prompt caching.
|
||||||
|
promptCacheKey: Schema.optional(Schema.String),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
@@ -289,6 +290,7 @@ export namespace LLMRequest {
|
|||||||
providerOptions: request.providerOptions,
|
providerOptions: request.providerOptions,
|
||||||
http: request.http,
|
http: request.http,
|
||||||
cache: request.cache,
|
cache: request.cache,
|
||||||
|
promptCacheKey: request.promptCacheKey,
|
||||||
metadata: request.metadata,
|
metadata: request.metadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
|||||||
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
||||||
reasoningField: Schema.optional(Schema.String),
|
reasoningField: Schema.optional(Schema.String),
|
||||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||||
|
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export namespace LanguageModelCompatibility {
|
export namespace LanguageModelCompatibility {
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Effect, JsonSchema, Schema } from "effect"
|
import { Effect, JsonSchema, Schema } from "effect"
|
||||||
import { Tool } from "@opencode-ai/schema/tool"
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import type {
|
import type { ToolCallPart, ToolDefinition as ToolDefinitionClass, ToolOutput as ToolOutputType } from "./schema"
|
||||||
ToolCallPart,
|
|
||||||
ToolDefinition as ToolDefinitionClass,
|
|
||||||
ToolOutput as ToolOutputType,
|
|
||||||
} from "./schema"
|
|
||||||
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
|
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,8 +240,7 @@ const project = (
|
|||||||
): ToolOutputType =>
|
): ToolOutputType =>
|
||||||
ToolOutput.make(
|
ToolOutput.make(
|
||||||
toStructuredOutput?.(output) ?? output,
|
toStructuredOutput?.(output) ?? output,
|
||||||
toModelOutput?.({ id, parameters, output }) ??
|
toModelOutput?.({ id, parameters, output }) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||||
(typeof output === "string" ? [{ type: "text", text: output }] : []),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ToolFailure }
|
export { ToolFailure }
|
||||||
|
|||||||
+1
-7
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache", "tool"],
|
||||||
"prefix:anthropic-messages-cache",
|
|
||||||
"provider:anthropic",
|
|
||||||
"protocol:anthropic-messages",
|
|
||||||
"cache",
|
|
||||||
"tool"
|
|
||||||
],
|
|
||||||
"name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback",
|
"name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback",
|
||||||
"recordedAt": "2026-07-24T16:22:29.494Z"
|
"recordedAt": "2026-07-24T16:22:29.494Z"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-6
@@ -1,12 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:bedrock-converse-cache", "provider:amazon-bedrock", "protocol:bedrock-converse", "cache"],
|
||||||
"prefix:bedrock-converse-cache",
|
|
||||||
"provider:amazon-bedrock",
|
|
||||||
"protocol:bedrock-converse",
|
|
||||||
"cache"
|
|
||||||
],
|
|
||||||
"name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call",
|
"name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call",
|
||||||
"recordedAt": "2026-07-23T02:29:10.955Z"
|
"recordedAt": "2026-07-23T02:29:10.955Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:google-images", "provider:google", "protocol:google-images"],
|
||||||
"prefix:google-images",
|
|
||||||
"provider:google",
|
|
||||||
"protocol:google-images"
|
|
||||||
],
|
|
||||||
"name": "google-images/generates-an-image",
|
"name": "google-images/generates-an-image",
|
||||||
"recordedAt": "2026-07-19T16:05:51.868Z"
|
"recordedAt": "2026-07-19T16:05:51.868Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:openai-images", "provider:openai", "protocol:openai-images"],
|
||||||
"prefix:openai-images",
|
|
||||||
"provider:openai",
|
|
||||||
"protocol:openai-images"
|
|
||||||
],
|
|
||||||
"name": "openai-images/generates-an-image",
|
"name": "openai-images/generates-an-image",
|
||||||
"recordedAt": "2026-07-19T14:41:43.188Z"
|
"recordedAt": "2026-07-19T14:41:43.188Z"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-5
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:openai-responses-images", "provider:openai", "protocol:openai-responses"],
|
||||||
"prefix:openai-responses-images",
|
|
||||||
"provider:openai",
|
|
||||||
"protocol:openai-responses"
|
|
||||||
],
|
|
||||||
"name": "openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool",
|
"name": "openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool",
|
||||||
"recordedAt": "2026-07-19T14:57:16.284Z"
|
"recordedAt": "2026-07-19T14:57:16.284Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"tags": [
|
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"],
|
||||||
"prefix:openai-compatible-chat",
|
|
||||||
"provider:openrouter",
|
|
||||||
"protocol:openai-chat",
|
|
||||||
"reasoning"
|
|
||||||
],
|
|
||||||
"name": "openrouter-reasoning",
|
"name": "openrouter-reasoning",
|
||||||
"recordedAt": "2026-07-18T11:28:39.267Z"
|
"recordedAt": "2026-07-18T11:28:39.267Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:anthropic",
|
|
||||||
"protocol:anthropic-messages",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/anthropic-tool-result",
|
"name": "pdf/anthropic-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:15:39.002Z"
|
"recordedAt": "2026-07-22T18:15:39.002Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:anthropic",
|
|
||||||
"protocol:anthropic-messages",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/anthropic-user-input",
|
"name": "pdf/anthropic-user-input",
|
||||||
"recordedAt": "2026-07-22T18:15:37.979Z"
|
"recordedAt": "2026-07-22T18:15:37.979Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:amazon-bedrock",
|
|
||||||
"protocol:bedrock-converse",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/bedrock-tool-result",
|
"name": "pdf/bedrock-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:15:52.400Z"
|
"recordedAt": "2026-07-22T18:15:52.400Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:amazon-bedrock",
|
|
||||||
"protocol:bedrock-converse",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/bedrock-user-input",
|
"name": "pdf/bedrock-user-input",
|
||||||
"recordedAt": "2026-07-22T18:15:48.408Z"
|
"recordedAt": "2026-07-22T18:15:48.408Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:google",
|
|
||||||
"protocol:gemini",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/gemini-tool-result",
|
"name": "pdf/gemini-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:21:59.606Z"
|
"recordedAt": "2026-07-22T18:21:59.606Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:google",
|
|
||||||
"protocol:gemini",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/gemini-user-input",
|
"name": "pdf/gemini-user-input",
|
||||||
"recordedAt": "2026-07-22T18:20:55.140Z"
|
"recordedAt": "2026-07-22T18:20:55.140Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:openai",
|
|
||||||
"protocol:openai-responses",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/openai-tool-result",
|
"name": "pdf/openai-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:15:36.438Z"
|
"recordedAt": "2026-07-22T18:15:36.438Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:openai",
|
|
||||||
"protocol:openai-responses",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/openai-user-input",
|
"name": "pdf/openai-user-input",
|
||||||
"recordedAt": "2026-07-22T18:15:34.867Z"
|
"recordedAt": "2026-07-22T18:15:34.867Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:xai",
|
|
||||||
"protocol:openai-responses",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/xai-tool-result",
|
"name": "pdf/xai-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:15:43.608Z"
|
"recordedAt": "2026-07-22T18:15:43.608Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:xai",
|
|
||||||
"protocol:openai-responses",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/xai-user-input",
|
"name": "pdf/xai-user-input",
|
||||||
"recordedAt": "2026-07-22T18:15:42.429Z"
|
"recordedAt": "2026-07-22T18:15:42.429Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"tags": [
|
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"],
|
||||||
"prefix:openai-compatible-chat",
|
|
||||||
"provider:vercel-ai-gateway",
|
|
||||||
"protocol:openai-chat",
|
|
||||||
"reasoning"
|
|
||||||
],
|
|
||||||
"name": "vercel-ai-gateway-reasoning",
|
"name": "vercel-ai-gateway-reasoning",
|
||||||
"recordedAt": "2026-07-18T11:28:42.077Z"
|
"recordedAt": "2026-07-18T11:28:42.077Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:xai-images", "provider:xai", "protocol:xai-images"],
|
||||||
"prefix:xai-images",
|
|
||||||
"provider:xai",
|
|
||||||
"protocol:xai-images"
|
|
||||||
],
|
|
||||||
"name": "xai-images/generates-an-image",
|
"name": "xai-images/generates-an-image",
|
||||||
"recordedAt": "2026-07-19T15:56:20.098Z"
|
"recordedAt": "2026-07-19T15:56:20.098Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
|
|||||||
const updated = LanguageModel.update(base, {
|
const updated = LanguageModel.update(base, {
|
||||||
route: responsesRoute,
|
route: responsesRoute,
|
||||||
defaults: { generation: { maxTokens: 20 } },
|
defaults: { generation: { maxTokens: 20 } },
|
||||||
compatibility: { toolSchema: "gemini" },
|
compatibility: { toolSchema: "gemini", requireFinishReason: false },
|
||||||
})
|
})
|
||||||
const updatedInput = LanguageModel.input(updated)
|
const updatedInput = LanguageModel.input(updated)
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
|
|||||||
expect(String(updated.id)).toBe("fake-model")
|
expect(String(updated.id)).toBe("fake-model")
|
||||||
expect(updated.route).toBe(responsesRoute)
|
expect(updated.route).toBe(responsesRoute)
|
||||||
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
||||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
|
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
|
||||||
expect(updatedInput.defaults).toBe(updated.defaults)
|
expect(updatedInput.defaults).toBe(updated.defaults)
|
||||||
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
||||||
expect(String(updatedInput.provider)).toBe("fake")
|
expect(String(updatedInput.provider)).toBe("fake")
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { CloudflareWorkersAI } from "../../src/providers"
|
|||||||
|
|
||||||
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
|
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
|
||||||
|
|
||||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
|
LLM.request({ model, prompt: "Hello", promptCacheKey: "cache" })
|
||||||
|
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model,
|
model,
|
||||||
prompt: "Hello",
|
prompt: "Hello",
|
||||||
// @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
|
// @ts-expect-error Prompt cache keys must be strings.
|
||||||
providerOptions: { openai: { promptCacheKey: 1 } },
|
promptCacheKey: 1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -209,6 +209,27 @@ describe("provider package entrypoints", () => {
|
|||||||
expect(chat.route.id).toBe("azure-openai-chat")
|
expect(chat.route.id).toBe("azure-openai-chat")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
|
||||||
|
const Azure = await import("@opencode-ai/ai/providers/azure")
|
||||||
|
const deployment = Azure.model("custom-deployment", {
|
||||||
|
apiKey: "fixture",
|
||||||
|
resourceName: "opencode-test",
|
||||||
|
apiVersion: "2025-01-01-preview",
|
||||||
|
useDeploymentBasedUrls: true,
|
||||||
|
})
|
||||||
|
const gateway = Azure.model("gateway-model", {
|
||||||
|
apiKey: "fixture",
|
||||||
|
baseURL: "https://gateway.example/azure/",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(deployment.route.endpoint).toMatchObject({
|
||||||
|
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
|
||||||
|
query: { "api-version": "2025-01-01-preview" },
|
||||||
|
})
|
||||||
|
expect(gateway.route.endpoint.baseURL).toBe("https://gateway.example/azure")
|
||||||
|
expect(gateway.route.endpoint.query).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("maps Google package settings onto the Gemini model", async () => {
|
test("maps Google package settings onto the Gemini model", async () => {
|
||||||
const Google = await import("@opencode-ai/ai/providers/google")
|
const Google = await import("@opencode-ai/ai/providers/google")
|
||||||
const selected = Google.model("gemini-2.5-flash", {
|
const selected = Google.model("gemini-2.5-flash", {
|
||||||
|
|||||||
@@ -519,14 +519,8 @@ describe("Bedrock Converse route", () => {
|
|||||||
fixedBytes(
|
fixedBytes(
|
||||||
eventStreamBody(
|
eventStreamBody(
|
||||||
["messageStart", { role: "assistant" }],
|
["messageStart", { role: "assistant" }],
|
||||||
[
|
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
|
||||||
"contentBlockDelta",
|
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||||
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"contentBlockDelta",
|
|
||||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
|
||||||
],
|
|
||||||
["messageStop", { stopReason: "end_turn" }],
|
["messageStop", { stopReason: "end_turn" }],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -561,10 +555,7 @@ describe("Bedrock Converse route", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = eventStreamBody(
|
const body = eventStreamBody(
|
||||||
["messageStart", { role: "assistant" }],
|
["messageStart", { role: "assistant" }],
|
||||||
[
|
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||||
"contentBlockDelta",
|
|
||||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
|
||||||
],
|
|
||||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||||
["messageStop", { stopReason: "end_turn" }],
|
["messageStop", { stopReason: "end_turn" }],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const model = Gemini.route
|
|||||||
})
|
})
|
||||||
.model({ id: "gemini-2.5-flash" })
|
.model({ id: "gemini-2.5-flash" })
|
||||||
|
|
||||||
|
const gemini3 = Gemini.route
|
||||||
|
.with({
|
||||||
|
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||||
|
auth: Auth.header("x-goog-api-key", "test"),
|
||||||
|
})
|
||||||
|
.model({ id: "gemini-3-flash-preview" })
|
||||||
|
|
||||||
const request = LLM.request({
|
const request = LLM.request({
|
||||||
id: "req_1",
|
id: "req_1",
|
||||||
model,
|
model,
|
||||||
@@ -86,6 +93,39 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("forwards standard Gemini generation options", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Say hello.",
|
||||||
|
generation: {
|
||||||
|
maxTokens: 40,
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.8,
|
||||||
|
topK: 12,
|
||||||
|
frequencyPenalty: 0.3,
|
||||||
|
presencePenalty: 0.4,
|
||||||
|
seed: 42,
|
||||||
|
stop: ["done"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.generationConfig).toEqual({
|
||||||
|
maxOutputTokens: 40,
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.8,
|
||||||
|
topK: 12,
|
||||||
|
frequencyPenalty: 0.3,
|
||||||
|
presencePenalty: 0.4,
|
||||||
|
seed: 42,
|
||||||
|
stopSequences: ["done"],
|
||||||
|
thinkingConfig: undefined,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -350,6 +390,100 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves nested empty object tool schemas", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Use the tool.",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "configure",
|
||||||
|
description: "Configure the operation",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["options"],
|
||||||
|
properties: {
|
||||||
|
options: { type: "object", description: "Optional provider settings", properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.tools).toEqual([
|
||||||
|
{
|
||||||
|
functionDeclarations: [
|
||||||
|
{
|
||||||
|
name: "configure",
|
||||||
|
description: "Configure the operation",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["options"],
|
||||||
|
properties: {
|
||||||
|
options: { type: "object", description: "Optional provider settings", properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("projects Gemini type arrays without narrowing their allowed values", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Use the tool.",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "filter",
|
||||||
|
description: "Filter values",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: ["number", "string"], description: "Status filter" },
|
||||||
|
maybe: { type: ["string", "null"] },
|
||||||
|
nothing: { type: ["null"] },
|
||||||
|
explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
|
choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: {
|
||||||
|
description: "Status filter",
|
||||||
|
anyOf: [{ type: "number" }, { type: "string" }],
|
||||||
|
},
|
||||||
|
maybe: {
|
||||||
|
nullable: true,
|
||||||
|
anyOf: [{ type: "string" }],
|
||||||
|
},
|
||||||
|
nothing: {
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
explicit: {
|
||||||
|
type: "string",
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
choice: {
|
||||||
|
anyOf: [{ type: "string" }, { type: "number" }],
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
@@ -536,6 +670,44 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: gemini3,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]),
|
||||||
|
Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||||
|
thoughtSignature: "skip_thought_signature_validator",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
id: undefined,
|
||||||
|
name: "lookup",
|
||||||
|
response: { name: "lookup", content: "done" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents({
|
const body = sseEvents({
|
||||||
|
|||||||
@@ -56,13 +56,14 @@ describe("Google Vertex providers", () => {
|
|||||||
|
|
||||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const model = GoogleVertexMessages.configure({
|
||||||
|
accessToken: "vertex-token",
|
||||||
|
location: "eu",
|
||||||
|
project: "vertex-project",
|
||||||
|
}).model("claude-sonnet-4-6")
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: GoogleVertexMessages.configure({
|
model,
|
||||||
accessToken: "vertex-token",
|
|
||||||
location: "eu",
|
|
||||||
project: "vertex-project",
|
|
||||||
}).model("claude-sonnet-4-6"),
|
|
||||||
prompt: "Say hello.",
|
prompt: "Say hello.",
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -97,6 +98,7 @@ describe("Google Vertex providers", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
expect(model.provider).toBe("google-vertex")
|
||||||
expect(response.text).toBe("Hello.")
|
expect(response.text).toBe("Hello.")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from "../../src"
|
} from "../../src"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
|
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||||
|
import * as XAI from "../../src/providers/xai"
|
||||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||||
import { ProviderShared } from "../../src/protocols/shared"
|
import { ProviderShared } from "../../src/protocols/shared"
|
||||||
import { Auth, LLMClient } from "../../src/route"
|
import { Auth, LLMClient } from "../../src/route"
|
||||||
@@ -100,6 +102,24 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("concatenates assistant text parts without adding separators", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
{ type: "text", text: " world" },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
|
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -154,6 +174,47 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("maps the request prompt cache key", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAICompatible.configure({
|
||||||
|
baseURL: "https://api.compatible.test/v1",
|
||||||
|
apiKey: "test",
|
||||||
|
}).model("compatible-model"),
|
||||||
|
prompt: "Hello",
|
||||||
|
promptCacheKey: "session_123",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.prompt_cache_key).toBe("session_123")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
|
||||||
|
LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
|
||||||
|
prompt: "Hello",
|
||||||
|
promptCacheKey: "session_123",
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
|
||||||
|
const body = decodeJson(yield* Effect.promise(() => web.text()))
|
||||||
|
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
|
||||||
|
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
|
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -535,7 +596,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
|
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -784,7 +845,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("ignores scalar reasoning after content starts", () =>
|
it.effect("preserves scalar reasoning after content starts", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
@@ -800,11 +861,11 @@ describe("OpenAI Chat route", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.reasoning).toBe("detail")
|
expect(response.reasoning).toBe("detailscalar")
|
||||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
|
||||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
|
||||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
openai: { reasoningDetails: details },
|
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -904,7 +965,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||||
|
|
||||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -954,7 +1015,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(replay.body.messages).toEqual([
|
expect(replay.body.messages).toEqual([
|
||||||
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
|
{ role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -979,7 +1040,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(replay.body.messages).toEqual([
|
expect(replay.body.messages).toEqual([
|
||||||
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
|
{ role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1001,7 +1062,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(replay.body.messages).toEqual([
|
expect(replay.body.messages).toEqual([
|
||||||
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
|
{ role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1109,7 +1170,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
|
it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
deltaChunk({
|
deltaChunk({
|
||||||
@@ -1121,27 +1182,31 @@ describe("OpenAI Chat route", () => {
|
|||||||
const input = LLMRequest.update(request, {
|
const input = LLMRequest.update(request, {
|
||||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||||
})
|
})
|
||||||
const events: LLMEvent[] = []
|
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
|
||||||
const streamError = yield* LLMClient.stream(input).pipe(
|
|
||||||
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
|
|
||||||
Effect.flip,
|
|
||||||
Effect.provide(fixedResponse(body)),
|
|
||||||
)
|
|
||||||
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
|
||||||
|
|
||||||
expect(events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "step-start", index: 0 },
|
{ type: "step-start", index: 0 },
|
||||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||||
|
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerExecuted: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "step-finish",
|
||||||
|
index: 0,
|
||||||
|
reason: { normalized: "tool-calls" },
|
||||||
|
usage: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
{ type: "finish", reason: { normalized: "tool-calls" }, usage: undefined },
|
||||||
])
|
])
|
||||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
|
||||||
expect(streamError.reason).toMatchObject({
|
|
||||||
_tag: "InvalidProviderOutput",
|
|
||||||
classification: "incomplete-stream",
|
|
||||||
})
|
|
||||||
expect(streamError.message).toContain("The provider response ended unexpectedly.")
|
|
||||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { compileRequest } from "../../src/route/client"
|
|||||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
import { dynamicResponse } from "../lib/http"
|
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||||
import { sseEvents } from "../lib/sse"
|
import { sseEvents } from "../lib/sse"
|
||||||
|
|
||||||
const Json = Schema.fromJsonString(Schema.Unknown)
|
const Json = Schema.fromJsonString(Schema.Unknown)
|
||||||
@@ -253,4 +253,108 @@ describe("OpenAI-compatible Chat route", () => {
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("accepts nullable usage and preserves provider fields", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
deltaChunk({ content: "Hello" }),
|
||||||
|
deltaChunk({}, "stop"),
|
||||||
|
usageChunk({
|
||||||
|
prompt_tokens: null,
|
||||||
|
completion_tokens: null,
|
||||||
|
total_tokens: null,
|
||||||
|
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
|
||||||
|
completion_tokens_details: {
|
||||||
|
reasoning_tokens: null,
|
||||||
|
accepted_prediction_tokens: null,
|
||||||
|
rejected_prediction_tokens: null,
|
||||||
|
},
|
||||||
|
cost: "0.001",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.usage).toMatchObject({
|
||||||
|
inputTokens: undefined,
|
||||||
|
outputTokens: undefined,
|
||||||
|
totalTokens: undefined,
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
prompt_tokens: null,
|
||||||
|
completion_tokens: null,
|
||||||
|
total_tokens: null,
|
||||||
|
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
|
||||||
|
cost: "0.001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("assembles indexless parallel tool calls across sparse chunks", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(request, {
|
||||||
|
tools: [
|
||||||
|
ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
deltaChunk({
|
||||||
|
tool_calls: [
|
||||||
|
{ id: "call_paris", function: { name: "weather", arguments: '{"city":"' } },
|
||||||
|
{ index: null, id: "call_london", function: { name: "weather", arguments: '{"city":"' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
deltaChunk({ tool_calls: [{ function: { arguments: 'London"}' } }] }),
|
||||||
|
deltaChunk({ tool_calls: [{ id: "call_paris", function: { arguments: 'Paris"}' } }] }),
|
||||||
|
deltaChunk({}, "tool_calls"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.toolCalls).toMatchObject([
|
||||||
|
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
|
||||||
|
{ id: "call_london", name: "weather", input: { city: "London" } },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("treats an empty finish reason as terminal", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects content after a terminal chunk", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
deltaChunk({ content: "Hello" }),
|
||||||
|
deltaChunk({}, "stop"),
|
||||||
|
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{}" } }] }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const cacheRequest = LLM.request({
|
|||||||
system: LARGE_CACHEABLE_SYSTEM,
|
system: LARGE_CACHEABLE_SYSTEM,
|
||||||
prompt: "Say hi.",
|
prompt: "Say hi.",
|
||||||
generation: { maxTokens: 16, temperature: 0 },
|
generation: { maxTokens: 16, temperature: 0 },
|
||||||
providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
|
promptCacheKey: "recorded-cache-test",
|
||||||
})
|
})
|
||||||
|
|
||||||
const recorded = recordedTests({
|
const recorded = recordedTests({
|
||||||
|
|||||||
@@ -682,9 +682,9 @@ describe("OpenAI Responses route", () => {
|
|||||||
LLM.request({
|
LLM.request({
|
||||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||||
prompt: "think",
|
prompt: "think",
|
||||||
|
promptCacheKey: "session_123",
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
openai: {
|
openai: {
|
||||||
promptCacheKey: "session_123",
|
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
include: ["reasoning.encrypted_content"],
|
include: ["reasoning.encrypted_content"],
|
||||||
@@ -803,17 +803,16 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("request OpenAI provider options override route defaults", () =>
|
it.effect("maps the request prompt cache key", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: OpenAI.configure({
|
model: OpenAI.configure({
|
||||||
baseURL: "https://api.openai.test/v1/",
|
baseURL: "https://api.openai.test/v1/",
|
||||||
apiKey: "test",
|
apiKey: "test",
|
||||||
providerOptions: { openai: { promptCacheKey: "model_cache" } },
|
|
||||||
}).model("gpt-4.1-mini"),
|
}).model("gpt-4.1-mini"),
|
||||||
prompt: "no cache",
|
prompt: "no cache",
|
||||||
providerOptions: { openai: { promptCacheKey: "request_cache" } },
|
promptCacheKey: "request_cache",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ describe("OpenRouter", () => {
|
|||||||
openrouter: {
|
openrouter: {
|
||||||
usage: true,
|
usage: true,
|
||||||
reasoning: { effort: "high" },
|
reasoning: { effort: "high" },
|
||||||
promptCacheKey: "session_123",
|
|
||||||
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
||||||
provider: { order: ["anthropic", "google"], require_parameters: true },
|
provider: { order: ["anthropic", "google"], require_parameters: true },
|
||||||
plugins: [{ id: "response-healing" }],
|
plugins: [{ id: "response-healing" }],
|
||||||
@@ -174,6 +173,7 @@ describe("OpenRouter", () => {
|
|||||||
},
|
},
|
||||||
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
||||||
prompt: "Think briefly.",
|
prompt: "Think briefly.",
|
||||||
|
promptCacheKey: "session_123",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
# V1 API Migration Checklist
|
|
||||||
|
|
||||||
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
- [x] Replace `GET /global/event` with `GET /api/event`.
|
|
||||||
- `src/context/server-sdk.tsx`
|
|
||||||
- [x] Reduce current granular session and message events into the existing app projections.
|
|
||||||
- `src/context/server-session-v2-reducer.ts`
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- `src/context/notification.tsx`
|
|
||||||
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
|
||||||
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- [x] Adapt current permission and question events to the existing request model.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
- `src/context/permission.tsx`
|
|
||||||
- [x] Consume current file watcher events.
|
|
||||||
- `src/context/file.tsx`
|
|
||||||
- [x] Consume current VCS events.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Consume current `pty.exited` events.
|
|
||||||
- `src/context/terminal.tsx`
|
|
||||||
- [ ] Migrate LSP and reference events.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
|
|
||||||
## Sessions
|
|
||||||
|
|
||||||
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Migrate session listing from `GET /session`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- `src/context/directory-sync.ts`
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
|
||||||
- `src/components/titlebar.tsx`
|
|
||||||
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
|
||||||
- `src/context/directory-sync.ts`
|
|
||||||
- `src/context/layout.tsx`
|
|
||||||
- `src/pages/home.tsx`
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
- `src/pages/session/timeline/message-timeline.tsx`
|
|
||||||
- `src/components/titlebar-tab-nav.tsx`
|
|
||||||
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
|
||||||
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
|
||||||
- `src/pages/session/timeline/message-timeline.tsx`
|
|
||||||
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
|
||||||
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
|
||||||
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
|
||||||
- `src/components/prompt-input/submit.ts`
|
|
||||||
- `src/pages/session/use-session-commands.tsx`
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
|
||||||
- `src/pages/session/use-session-commands.tsx`
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
|
||||||
- `src/pages/session/use-session-commands.tsx`
|
|
||||||
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
|
||||||
- `src/components/prompt-input/submit.ts`
|
|
||||||
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
|
||||||
- `src/components/prompt-input/submit.ts`
|
|
||||||
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
|
||||||
- `src/components/dialog-fork.tsx`
|
|
||||||
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
|
||||||
- `src/pages/session/use-session-commands.tsx`
|
|
||||||
- `src/pages/session/timeline/message-timeline.tsx`
|
|
||||||
- Blocked: the current API has no sharing contract or implementation.
|
|
||||||
|
|
||||||
## Session Compatibility Fallbacks
|
|
||||||
|
|
||||||
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
|
||||||
|
|
||||||
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
|
|
||||||
## Filesystem
|
|
||||||
|
|
||||||
- [ ] Migrate file listing from `GET /file`.
|
|
||||||
- `src/context/file.tsx`
|
|
||||||
- [ ] Migrate file reads from `GET /file/content`.
|
|
||||||
- `src/context/file.tsx`
|
|
||||||
- `src/pages/session/review-tab.tsx`
|
|
||||||
- `src/pages/session/v2/review-panel-v2.tsx`
|
|
||||||
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- `src/components/dialog-select-directory.tsx`
|
|
||||||
- `src/components/dialog-select-directory-v2.tsx`
|
|
||||||
|
|
||||||
## Projects And Worktrees
|
|
||||||
|
|
||||||
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
|
||||||
- `src/context/layout.tsx`
|
|
||||||
- `src/components/edit-project.ts`
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
- `src/components/prompt-input/submit.ts`
|
|
||||||
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
|
||||||
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
|
|
||||||
## VCS
|
|
||||||
|
|
||||||
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
|
|
||||||
## Configuration And Authentication
|
|
||||||
|
|
||||||
- [ ] Migrate global configuration reads from `GET /global/config`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [ ] Migrate directory configuration reads from `GET /config`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
|
||||||
- `src/components/dialog-connect-provider.tsx`
|
|
||||||
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
|
||||||
- `src/components/dialog-connect-provider.tsx`
|
|
||||||
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
|
||||||
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
|
||||||
- `src/components/dialog-connect-provider.tsx`
|
|
||||||
- `src/components/dialog-custom-provider.tsx`
|
|
||||||
- `src/components/settings-providers.tsx`
|
|
||||||
- `src/components/settings-v2/providers.tsx`
|
|
||||||
- [ ] Migrate global disposal from `POST /global/dispose`.
|
|
||||||
- `src/components/dialog-connect-provider.tsx`
|
|
||||||
- `src/components/settings-providers.tsx`
|
|
||||||
- `src/components/settings-v2/providers.tsx`
|
|
||||||
|
|
||||||
## Permissions And Questions
|
|
||||||
|
|
||||||
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- `src/context/permission.tsx`
|
|
||||||
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
|
||||||
- `src/context/permission.tsx`
|
|
||||||
- `src/pages/session/composer/session-composer-state.ts`
|
|
||||||
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
|
||||||
- `src/pages/session/composer/session-question-dock.tsx`
|
|
||||||
|
|
||||||
## Commands, MCP, LSP, And References
|
|
||||||
|
|
||||||
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [ ] Migrate LSP status from `GET /lsp`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
|
|
||||||
## Search
|
|
||||||
|
|
||||||
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
|
||||||
- `src/components/command-palette.ts`
|
|
||||||
- `src/components/dialog-command-palette-v2.tsx`
|
|
||||||
|
|
||||||
## PTY And Terminal
|
|
||||||
|
|
||||||
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
|
||||||
- `src/context/terminal.tsx`
|
|
||||||
- `src/components/terminal.tsx`
|
|
||||||
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
|
||||||
- `src/components/settings-general.tsx`
|
|
||||||
- `src/components/settings-v2/general.tsx`
|
|
||||||
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
|
||||||
- `src/components/terminal.tsx`
|
|
||||||
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
|
||||||
- `src/components/terminal.tsx`
|
|
||||||
|
|
||||||
## Legacy Types And Adapters
|
|
||||||
|
|
||||||
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
|
||||||
|
|
||||||
- [ ] Replace the current-session-to-legacy-session adapter.
|
|
||||||
- `src/utils/session.ts`
|
|
||||||
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
|
||||||
- `src/utils/session-message.ts`
|
|
||||||
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
|
||||||
- `src/context/global-sync/utils.ts`
|
|
||||||
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
|
||||||
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
|
||||||
- `package.json`
|
|
||||||
|
|
||||||
## Test Infrastructure
|
|
||||||
|
|
||||||
- [ ] Replace V1 endpoint mocks with current API mocks.
|
|
||||||
- `e2e/utils/mock-server.ts`
|
|
||||||
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
|
||||||
- `e2e/utils/sse-transport.ts`
|
|
||||||
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
|
||||||
- `e2e/performance/timeline-stability/fixture.ts`
|
|
||||||
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
|
||||||
@@ -45,10 +45,6 @@ describe("timeline fixture validation", () => {
|
|||||||
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||||
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses the projected tool ID as its call ID", () => {
|
|
||||||
expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (false) {
|
if (false) {
|
||||||
|
|||||||
@@ -2,15 +2,8 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||||
import type {
|
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
AssistantMessage,
|
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||||
Message,
|
|
||||||
Part,
|
|
||||||
ToolPart,
|
|
||||||
ToolState,
|
|
||||||
UserMessage,
|
|
||||||
} from "../../../src/types"
|
|
||||||
import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
|
|
||||||
import { expect, type Page } from "@playwright/test"
|
import { expect, type Page } from "@playwright/test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
@@ -25,7 +18,7 @@ export const assistantID = "msg_1001_timeline_assistant"
|
|||||||
export const title = "Timeline visual stability"
|
export const title = "Timeline visual stability"
|
||||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
type Session = SessionV1Info
|
type Session = SessionInfo
|
||||||
type GlobalEvent = {
|
type GlobalEvent = {
|
||||||
directory: string
|
directory: string
|
||||||
project?: string
|
project?: string
|
||||||
@@ -537,11 +530,11 @@ export function project() {
|
|||||||
export function session(input: Partial<Session> = {}): Session {
|
export function session(input: Partial<Session> = {}): Session {
|
||||||
return {
|
return {
|
||||||
id: sessionID,
|
id: sessionID,
|
||||||
slug: "timeline-stability",
|
|
||||||
projectID,
|
projectID,
|
||||||
directory,
|
location: { directory },
|
||||||
title,
|
title,
|
||||||
version: "dev",
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
...input,
|
...input,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ test("shows all and expands historical diff summary without overlap", async ({ p
|
|||||||
summary: {
|
summary: {
|
||||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||||
file: `src/diff-${index}.ts`,
|
file: `src/diff-${index}.ts`,
|
||||||
|
status: "modified",
|
||||||
additions: 1,
|
additions: 1,
|
||||||
deletions: 1,
|
deletions: 1,
|
||||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||||
|
|||||||
@@ -85,8 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
return sse(route)
|
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
|||||||
@@ -181,12 +181,17 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
return json(route, true)
|
return json(route, true)
|
||||||
}
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
return sse(route)
|
|
||||||
if (url.pathname === "/api/provider")
|
if (url.pathname === "/api/provider")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: { directory },
|
location: { directory },
|
||||||
data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
|
data: [
|
||||||
|
{
|
||||||
|
id: remote ? "server-b" : "server-a",
|
||||||
|
name: remote ? "Server B Provider" : "Server A Provider",
|
||||||
|
package: "test",
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
|
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
|
||||||
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
|
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ async function mockServers(page: Page) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event")
|
||||||
return sse(route, url.pathname === "/api/event")
|
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/api/session/active")
|
if (url.pathname === "/api/session/active")
|
||||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||||
|
|||||||
+8
-10
@@ -1,19 +1,18 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
|
||||||
const draftID = "draft_legacy_new_session"
|
const draftID = "draft_removed_layout_preference"
|
||||||
const directory = "C:/OpenCode/LegacyNewSession"
|
const directory = "C:/OpenCode/RemovedLayoutPreference"
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_legacy_new_session",
|
id: "proj_removed_layout_preference",
|
||||||
worktree: directory,
|
worktree: directory,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
name: "legacy-new-session",
|
name: "removed-layout-preference",
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
sandboxes: [],
|
sandboxes: [],
|
||||||
},
|
},
|
||||||
@@ -24,7 +23,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
@@ -35,7 +33,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/new-session?draftId=${draftID}`)
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
|
||||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
|
||||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
|
||||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
|
||||||
})
|
})
|
||||||
@@ -171,8 +171,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
expect(bottomGap).toBeLessThanOrEqual(16)
|
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||||
const lazyDiff = page.waitForRequest((request) => {
|
const lazyDiff = page.waitForRequest((request) => {
|
||||||
const url = new URL(request.url())
|
const url = new URL(request.url())
|
||||||
return (
|
return (
|
||||||
url.pathname === "/api/vcs/diff" &&
|
url.pathname === "/api/vcs/diff" &&
|
||||||
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
|
|||||||
const sessionID = "ses_context_resize_regression"
|
const sessionID = "ses_context_resize_regression"
|
||||||
const title = "Context resize regression"
|
const title = "Context resize regression"
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
||||||
const followingTextID = "prt_0104_text"
|
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||||
@@ -295,7 +295,7 @@ function contextTool(
|
|||||||
sessionID,
|
sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: `call_${partID}`,
|
callID: partID,
|
||||||
tool,
|
tool,
|
||||||
state: {
|
state: {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -86,15 +86,10 @@ test.describe("session timeline projection", () => {
|
|||||||
],
|
],
|
||||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||||
)
|
)
|
||||||
const aborted = assistantMessage(
|
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
|
||||||
[
|
id: "msg_1001_assistant_aborted",
|
||||||
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||||
],
|
})
|
||||||
{
|
|
||||||
id: "msg_1001_assistant_aborted",
|
|
||||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||||
id: "msg_1002_assistant_failed",
|
id: "msg_1002_assistant_failed",
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type EventPayload = {
|
|||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||||
|
|
||||||
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
||||||
test.setTimeout(90_000)
|
test.setTimeout(90_000)
|
||||||
const events: EventPayload[] = []
|
const events: EventPayload[] = []
|
||||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||||
@@ -86,28 +86,8 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
|||||||
await switchSession(page, otherID, otherTitle)
|
await switchSession(page, otherID, otherTitle)
|
||||||
await expect(dock).toHaveCount(0)
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
const returningOpen = sampleDock(page, 700)
|
|
||||||
await switchSession(page, sourceID, sourceTitle)
|
|
||||||
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
|
||||||
expect(openSamples.length).toBeGreaterThan(0)
|
|
||||||
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
|
||||||
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
|
||||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
|
||||||
|
|
||||||
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
|
||||||
const closing = sampleDock(page, 1_000)
|
|
||||||
todos[sourceID] = completedTodos
|
|
||||||
events.push(todoEvent(sourceID, completedTodos))
|
|
||||||
await expect(dock).toHaveCount(0)
|
|
||||||
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
|
||||||
todos[sourceID] = []
|
|
||||||
events.push(todoEvent(sourceID, []))
|
|
||||||
|
|
||||||
await switchSession(page, otherID, otherTitle)
|
|
||||||
const returningEmpty = sampleDock(page, 700)
|
|
||||||
await switchSession(page, sourceID, sourceTitle)
|
await switchSession(page, sourceID, sourceTitle)
|
||||||
await expect(dock).toHaveCount(0)
|
await expect(dock).toHaveCount(0)
|
||||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function session(id: string, title: string, created: number) {
|
function session(id: string, title: string, created: number) {
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ async function mockServer(page: Page) {
|
|||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||||
return new Promise(() => {})
|
return new Promise(() => {})
|
||||||
if (url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
return sse(route)
|
|
||||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
|||||||
const connection = new URL(connections[0]!)
|
const connection = new URL(connections[0]!)
|
||||||
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||||
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
||||||
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
|
expect(connection.searchParams.get("ticket")).toBeNull()
|
||||||
await writeProbe(page)
|
await writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
|
|||||||
@@ -300,9 +300,8 @@ export const fixture = {
|
|||||||
.filter((message) => message.info.role === "user")
|
.filter((message) => message.info.role === "user")
|
||||||
.map((message) => message.info.id),
|
.map((message) => message.info.id),
|
||||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||||
expandedShellPartID: targetMessages
|
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!
|
||||||
.flatMap((message) => message.parts)
|
.callID,
|
||||||
.find((part) => part.tool === "bash")!.callID,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (path === "/global/health")
|
if (path === "/global/health")
|
||||||
return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
|
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||||
if (path === "/api/health" && config.protocol === "v2")
|
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
if (path === "/provider") return json(route, providerConfig(config))
|
if (path === "/provider") return json(route, providerConfig(config))
|
||||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||||
@@ -145,7 +144,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
location: location(config),
|
location: location(config),
|
||||||
data: currentProviders(providerConfig(config)),
|
data: currentProviders(providerConfig(config)),
|
||||||
})
|
})
|
||||||
if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
if (path === "/api/model")
|
||||||
|
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
||||||
if (path === "/api/model/default")
|
if (path === "/api/model/default")
|
||||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||||
@@ -207,7 +207,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
||||||
if (fileRead && config.fileContent) {
|
if (fileRead && config.fileContent) {
|
||||||
const value = await config.fileContent(decodeURIComponent(fileRead))
|
const value = await config.fileContent(decodeURIComponent(fileRead))
|
||||||
const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
const content =
|
||||||
|
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||||
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
||||||
}
|
}
|
||||||
if (path === "/api/fs/find" && config.findFiles) {
|
if (path === "/api/fs/find" && config.findFiles) {
|
||||||
@@ -282,7 +283,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
|
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST")
|
||||||
|
return json(route, true)
|
||||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
||||||
return json(route, true)
|
return json(route, true)
|
||||||
if (
|
if (
|
||||||
@@ -387,11 +389,13 @@ function providerConfig(config: MockServerConfig) {
|
|||||||
|
|
||||||
function currentProviders(value: unknown) {
|
function currentProviders(value: unknown) {
|
||||||
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
||||||
return value.all.filter(record).flatMap((provider) =>
|
return value.all
|
||||||
typeof provider.id === "string" && typeof provider.name === "string"
|
.filter(record)
|
||||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
.flatMap((provider) =>
|
||||||
: [],
|
typeof provider.id === "string" && typeof provider.name === "string"
|
||||||
)
|
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentModels(value: unknown) {
|
function currentModels(value: unknown) {
|
||||||
@@ -441,9 +445,7 @@ function currentDefaultModel(value: unknown) {
|
|||||||
if (!record(value) || !record(value.default)) return null
|
if (!record(value) || !record(value.default)) return null
|
||||||
const selected = value.default
|
const selected = value.default
|
||||||
const models = currentModels(value)
|
const models = currentModels(value)
|
||||||
return models.find(
|
return models.find((model) => model.providerID === selected.providerID && model.id === selected.modelID) ?? null
|
||||||
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
|
|
||||||
) ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentPermission(value: unknown) {
|
function currentPermission(value: unknown) {
|
||||||
@@ -563,22 +565,16 @@ function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mentionFrom(value: Record<string, unknown> | undefined) {
|
function mentionFrom(value: Record<string, unknown> | undefined) {
|
||||||
if (
|
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number")
|
||||||
!value ||
|
|
||||||
typeof value.value !== "string" ||
|
|
||||||
typeof value.start !== "number" ||
|
|
||||||
typeof value.end !== "number"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
return { text: value.value, start: value.start, end: value.end }
|
return { text: value.value, start: value.start, end: value.end }
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacyAssistantContent(
|
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] {
|
||||||
part: Record<string, unknown>,
|
|
||||||
created: number,
|
|
||||||
): SessionMessageAssistant["content"] {
|
|
||||||
if (part.type === "text" && typeof part.text === "string")
|
if (part.type === "text" && typeof part.text === "string")
|
||||||
return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
|
return [
|
||||||
|
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
|
||||||
|
]
|
||||||
if (part.type === "reasoning" && typeof part.text === "string") {
|
if (part.type === "reasoning" && typeof part.text === "string") {
|
||||||
const time = record(part.time) ? part.time : undefined
|
const time = record(part.time) ? part.time : undefined
|
||||||
return [
|
return [
|
||||||
@@ -619,7 +615,12 @@ function legacyAssistantContent(
|
|||||||
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||||
}
|
}
|
||||||
if (state.status === "pending")
|
if (state.status === "pending")
|
||||||
return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
|
return [
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
|
||||||
|
},
|
||||||
|
]
|
||||||
if (state.status === "completed")
|
if (state.status === "completed")
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
|||||||
export type SseConnectionRecord = {
|
export type SseConnectionRecord = {
|
||||||
id: number
|
id: number
|
||||||
url: string
|
url: string
|
||||||
path: "/global/event" | "/event" | "/api/event"
|
path: "/api/event"
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
openedAt: number
|
openedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
@@ -174,11 +174,7 @@ export async function installSseTransport<T>(
|
|||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (
|
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
|
||||||
url.origin !== server ||
|
|
||||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
|
||||||
)
|
|
||||||
return originalFetch(request)
|
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
const record = {
|
const record = {
|
||||||
@@ -193,18 +189,9 @@ export async function installSseTransport<T>(
|
|||||||
record.controller = controller
|
record.controller = controller
|
||||||
connections.push(record)
|
connections.push(record)
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||||
if (url.pathname === "/api/event")
|
controller.enqueue(
|
||||||
controller.enqueue(
|
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
)
|
||||||
)
|
|
||||||
if (url.pathname === "/global/event")
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(
|
|
||||||
frame({
|
|
||||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
<body
|
||||||
|
data-new-layout
|
||||||
|
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
|
||||||
|
>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||||
<script src="/src/entry.tsx" type="module"></script>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"happy-dom": "20.11.1",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
@@ -56,7 +57,6 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
|
||||||
"@opencode-ai/session-ui": "workspace:*",
|
"@opencode-ai/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
|
|||||||
+47
-189
@@ -9,19 +9,9 @@ import { Font } from "@opencode-ai/ui/font"
|
|||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import {
|
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||||
type BaseRouterProps,
|
|
||||||
Navigate,
|
|
||||||
Route,
|
|
||||||
Router,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
useParams,
|
|
||||||
useSearchParams,
|
|
||||||
} from "@solidjs/router"
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
@@ -43,7 +33,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
|||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
@@ -54,58 +44,36 @@ import { PermissionProvider } from "@/context/permission"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import LegacyLayout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import NewLayout from "@/pages/layout-new"
|
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome } from "@/pages/home"
|
import { Home } from "@/pages/home"
|
||||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const SessionRoute = () => {
|
const DirectoryDraftRedirect = () => {
|
||||||
const settings = useSettings()
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
const sdk = useSDK()
|
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
if (params.id && settings.general.newLayoutDesigns()) {
|
|
||||||
const sessionID = params.id
|
|
||||||
return (
|
|
||||||
<Show when={tabs.ready()}>
|
|
||||||
{(_) => {
|
|
||||||
const persisted = tabs.store.filter((item) => item.type === "session")
|
|
||||||
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
|
||||||
// is replaced by a draft at /new-session?draftId=…
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!settings.general.newLayoutDesigns()) return
|
if (search.draftId || !tabs.ready()) return
|
||||||
if (params.id || search.draftId) return
|
const directory = decode64(params.dir)
|
||||||
if (!tabs.ready() || !sdk().directory) return
|
if (!directory) return
|
||||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return null
|
||||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
|
||||||
<SessionPage />
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetServerRoute(props: ParentProps) {
|
function TargetServerRoute(props: ParentProps) {
|
||||||
@@ -117,9 +85,7 @@ function TargetServerRoute(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must NOT remount this
|
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||||
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
|
||||||
// re-resolves reactively instead); both rely on this key for server changes.
|
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||||
@@ -134,35 +100,6 @@ const TargetSessionRoute = () => (
|
|||||||
</TargetServerRoute>
|
</TargetServerRoute>
|
||||||
)
|
)
|
||||||
|
|
||||||
function LegacyTargetSessionRoute() {
|
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
|
||||||
return (
|
|
||||||
<TargetServerRoute>
|
|
||||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
|
||||||
<LegacyTargetSessionRedirect />
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
</TargetServerRoute>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LegacyTargetSessionRedirect() {
|
|
||||||
const params = useParams<{ id: string }>()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const sync = useServerSync()
|
|
||||||
const current = createSessionLineage(
|
|
||||||
() => params.id,
|
|
||||||
() => sync().session.lineage,
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const directory = current()?.session.directory
|
|
||||||
if (!directory) return
|
|
||||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
@@ -175,17 +112,8 @@ function SelectedServerProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|
||||||
return (
|
|
||||||
<SelectedServerProviders>
|
|
||||||
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
|
||||||
</SelectedServerProviders>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const settings = useSettings()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show when={tabs.ready()}>
|
||||||
@@ -194,14 +122,7 @@ function DraftRoute() {
|
|||||||
keyed
|
keyed
|
||||||
fallback={<Navigate href="/" />}
|
fallback={<Navigate href="/" />}
|
||||||
>
|
>
|
||||||
{(draft) => (
|
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||||
<Show
|
|
||||||
when={settings.general.newLayoutDesigns()}
|
|
||||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
|
||||||
>
|
|
||||||
<ResolvedDraftRoute draft={draft} />
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -237,30 +158,6 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayoutCompatibility(props: ParentProps) {
|
|
||||||
const global = useGlobal()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const server = useServer()
|
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (settings.general.newLayoutDesigns()) return
|
|
||||||
const current = server.current
|
|
||||||
if (!current) return
|
|
||||||
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
|
||||||
if (protocol !== "v2") return
|
|
||||||
const next = global.servers.list().find((s) => {
|
|
||||||
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
|
||||||
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
|
||||||
})
|
|
||||||
if (!next) return
|
|
||||||
navigate("/")
|
|
||||||
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
|
||||||
})
|
|
||||||
|
|
||||||
return <>{props.children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
@@ -287,17 +184,11 @@ function QueryProvider(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BodyDesignClass() {
|
function BodyDesignClass() {
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
|
document.body.toggleAttribute("data-new-layout", true)
|
||||||
const enabled = settings.general.newLayoutDesigns()
|
document.body.classList.remove("text-12-regular")
|
||||||
document.body.toggleAttribute("data-new-layout", enabled)
|
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||||
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
|
return null
|
||||||
@@ -340,7 +231,6 @@ function DesktopCommands() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
@@ -355,19 +245,11 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
return (
|
|
||||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
|
||||||
</ServerScopedProviders>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectedServerProviders>
|
<SelectedServerProviders>
|
||||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||||
<NewLayout>{props.children}</NewLayout>
|
<Layout>{props.children}</Layout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
</SelectedServerProviders>
|
</SelectedServerProviders>
|
||||||
)
|
)
|
||||||
@@ -556,7 +438,7 @@ export function AppInterface(props: {
|
|||||||
startup?: Promise<void>
|
startup?: Promise<void>
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
// The visual new layout lives in the router root so it remains mounted across
|
// The visual layout lives in the router root so it remains mounted across
|
||||||
// route changes. Draft and session routes override only their server-bound data
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers beneath it.
|
// providers beneath it.
|
||||||
const ServerShell = (shellProps: ParentProps) => (
|
const ServerShell = (shellProps: ParentProps) => (
|
||||||
@@ -577,26 +459,22 @@ export function AppInterface(props: {
|
|||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
<Dynamic
|
||||||
<Dynamic
|
component={props.router ?? Router}
|
||||||
component={props.router ?? Router}
|
root={(routerProps) => (
|
||||||
root={(routerProps) => (
|
<TabsProvider>
|
||||||
<TabsProvider>
|
<PermissionProvider>
|
||||||
<PermissionProvider>
|
<NotificationProvider>
|
||||||
<NotificationProvider>
|
<ServerShell>
|
||||||
<ServerShell>
|
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
</ServerShell>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
</NotificationProvider>
|
||||||
</Show>
|
</PermissionProvider>
|
||||||
</ServerShell>
|
</TabsProvider>
|
||||||
</NotificationProvider>
|
)}
|
||||||
</PermissionProvider>
|
>
|
||||||
</TabsProvider>
|
<Routes />
|
||||||
)}
|
</Dynamic>
|
||||||
>
|
|
||||||
<Routes serverScoped={props.serverScoped} />
|
|
||||||
</Dynamic>
|
|
||||||
</Show>
|
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
@@ -604,40 +482,20 @@ export function AppInterface(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Routes(props: { serverScoped?: JSX.Element }) {
|
function Routes() {
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route
|
<Route path="/" component={Home} />
|
||||||
component={(routeProps) => (
|
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||||
)}
|
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||||
>
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
<Show when={!settings.general.newLayoutDesigns()}>
|
|
||||||
{
|
|
||||||
<>
|
|
||||||
<Route path="/" component={LegacyHome} />
|
|
||||||
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</Show>
|
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
<Show when={settings.general.newLayoutDesigns()}>
|
|
||||||
<Route path="/" component={NewHome} />
|
|
||||||
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
|
||||||
</Show>
|
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewLayoutLegacySessionRedirect() {
|
function LegacySessionRedirect() {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
@@ -14,7 +14,6 @@ import { useTabs } from "@/context/tabs"
|
|||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -146,7 +145,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
server: ServerConnection.key(serverSDK.server),
|
server: ServerConnection.key(serverSDK.server),
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
stored: () => serverCtx.sync.data.project,
|
||||||
load: (search, signal) => serverSDK.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
@@ -257,7 +256,6 @@ export function createServerSessionEntries(props: {
|
|||||||
.load(search, current.signal)
|
.load(search, current.signal)
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
result.data
|
result.data
|
||||||
.map(normalizeSessionInfo)
|
|
||||||
.filter((session) => !session.time.archived)
|
.filter((session) => !session.time.archived)
|
||||||
.map((session) => {
|
.map((session) => {
|
||||||
const project =
|
const project =
|
||||||
@@ -266,9 +264,9 @@ export function createServerSessionEntries(props: {
|
|||||||
id: `session:${props.server}:${session.id}`,
|
id: `session:${props.server}:${session.id}`,
|
||||||
type: "session" as const,
|
type: "session" as const,
|
||||||
title: session.title || props.untitled(),
|
title: session.title || props.untitled(),
|
||||||
description: project ? displayName(project) : getFilename(session.directory),
|
description: project ? displayName(project) : getFilename(session.location.directory),
|
||||||
category: props.category(),
|
category: props.category(),
|
||||||
directory: session.directory,
|
directory: session.location.directory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
server: props.server,
|
server: props.server,
|
||||||
project,
|
project,
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
|
|||||||
server: ServerConnection.key(props.server),
|
server: ServerConnection.key(props.server),
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
stored: () => serverCtx.sync.data.project,
|
||||||
load: (search, signal) => serverCtx.sdk.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ function ProviderConnection(props: {
|
|||||||
() => ({ provider: props.provider, directory: directory() }),
|
() => ({ provider: props.provider, directory: directory() }),
|
||||||
(input) =>
|
(input) =>
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.currentApi.integration.get({
|
.api.integration.get({
|
||||||
integrationID: input.provider,
|
integrationID: input.provider,
|
||||||
location: input.directory ? { directory: input.directory } : undefined,
|
location: input.directory ? { directory: input.directory } : undefined,
|
||||||
})
|
})
|
||||||
@@ -547,7 +547,7 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.currentApi.integration.oauth.connect({
|
.api.integration.oauth.connect({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
methodID: method.id,
|
methodID: method.id,
|
||||||
inputs: inputs ?? {},
|
inputs: inputs ?? {},
|
||||||
@@ -816,7 +816,7 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().currentApi.integration.connect.key({
|
await serverSDK().api.integration.connect.key({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
location: location(),
|
location: location(),
|
||||||
key: apiKey,
|
key: apiKey,
|
||||||
@@ -947,7 +947,7 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.currentApi.integration.oauth.complete({
|
.api.integration.oauth.complete({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
attemptID: store.authorization!.attemptID,
|
attemptID: store.authorization!.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
@@ -1044,7 +1044,7 @@ function ProviderConnection(props: {
|
|||||||
const authorization = store.authorization
|
const authorization = store.authorization
|
||||||
if (!authorization || !alive.value) return
|
if (!authorization || !alive.value) return
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.currentApi.integration.oauth.status({
|
.api.integration.oauth.status({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
attemptID: authorization.attemptID,
|
attemptID: authorization.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
|
|||||||
@@ -130,26 +130,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
|
||||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
throw new Error(`Custom provider ${result.providerID} is unavailable`)
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
|
||||||
|
|
||||||
if (result.key) {
|
|
||||||
await serverSDK().legacy.auth.set({
|
|
||||||
providerID: result.providerID,
|
|
||||||
auth: {
|
|
||||||
type: "api",
|
|
||||||
key: result.key,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await serverSync().updateConfig({
|
|
||||||
provider: { [result.providerID]: result.config },
|
|
||||||
disabled_providers: nextDisabled,
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function DialogEditProjectV2(props: { project: LocalProject; server: Serv
|
|||||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { For, Show } from "solid-js"
|
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { createEditProjectModel } from "./edit-project"
|
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const model = createEditProjectModel(props)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
|
||||||
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus
|
|
||||||
type="text"
|
|
||||||
label={language.t("dialog.project.edit.name")}
|
|
||||||
placeholder={model.folderName()}
|
|
||||||
value={model.store.name}
|
|
||||||
onChange={(v) => model.setStore("name", v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
|
|
||||||
<div class="flex gap-3 items-start">
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
|
||||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
|
||||||
classList={{
|
|
||||||
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
|
||||||
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
|
||||||
"overflow-hidden": !!model.store.iconOverride,
|
|
||||||
}}
|
|
||||||
onDrop={model.drop}
|
|
||||||
onDragOver={model.dragOver}
|
|
||||||
onDragLeave={model.dragLeave}
|
|
||||||
onClick={model.iconClick}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={getProjectAvatarSource(props.project.id, {
|
|
||||||
color: model.store.color,
|
|
||||||
url: props.project.icon?.url,
|
|
||||||
override: model.store.iconOverride,
|
|
||||||
})}
|
|
||||||
fallback={
|
|
||||||
<div class="size-full flex items-center justify-center">
|
|
||||||
<Avatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
{...getAvatarColors(model.store.color)}
|
|
||||||
class="size-full text-[32px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(src) => (
|
|
||||||
<img
|
|
||||||
src={src()}
|
|
||||||
alt={language.t("dialog.project.edit.icon.alt")}
|
|
||||||
class="size-full object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
|
||||||
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
|
||||||
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="icon-upload"
|
|
||||||
ref={(el) => {
|
|
||||||
model.setIconInput(el)
|
|
||||||
}}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
onChange={model.inputChange}
|
|
||||||
/>
|
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={!model.store.iconOverride}>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
|
||||||
<div class="flex gap-1.5">
|
|
||||||
<For each={AVATAR_COLOR_KEYS}>
|
|
||||||
{(color) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
|
||||||
aria-pressed={model.store.color === color}
|
|
||||||
classList={{
|
|
||||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
|
||||||
model.store.color === color,
|
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
|
||||||
model.store.color !== color,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (model.store.color === color && !props.project.icon?.url) return
|
|
||||||
model.setStore("color", model.store.color === color ? undefined : color)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
{...getAvatarColors(color)}
|
|
||||||
class="size-full rounded"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
label={language.t("dialog.project.edit.worktree.startup")}
|
|
||||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
|
||||||
value={model.store.startup}
|
|
||||||
onChange={(v) => model.setStore("startup", v)}
|
|
||||||
spellcheck={false}
|
|
||||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
|
|||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
|
|||||||
@@ -66,23 +66,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
let pathArea: HTMLDivElement | undefined
|
let pathArea: HTMLDivElement | undefined
|
||||||
let navigation = 0
|
let navigation = 0
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
() =>
|
||||||
if ((await sdk.protocol) === "v1")
|
sdk.api.location
|
||||||
return sdk.legacy.path.get().catch(() => undefined)
|
|
||||||
return sdk.api.location
|
|
||||||
.get()
|
.get()
|
||||||
.then((location) => ({
|
.then(
|
||||||
state: "",
|
(location): Path => ({
|
||||||
config: "",
|
state: "",
|
||||||
worktree: location.project.directory,
|
config: "",
|
||||||
directory: location.directory,
|
worktree: location.project.directory,
|
||||||
home: "",
|
directory: location.directory,
|
||||||
}))
|
home: "",
|
||||||
.catch(() => undefined)
|
}),
|
||||||
},
|
)
|
||||||
|
.catch(() => undefined),
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
@@ -104,7 +102,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const base = pickerRoot(cleaned) || root() || start()
|
const base = pickerRoot(cleaned) || root() || start()
|
||||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||||
const files = await sdk.currentApi.file
|
const files = await sdk.api.file
|
||||||
.find({
|
.find({
|
||||||
location: { directory: base },
|
location: { directory: base },
|
||||||
query: pickerFileSearchQuery(base, value, home()),
|
query: pickerFileSearchQuery(base, value, home()),
|
||||||
@@ -134,7 +132,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.currentApi.file
|
return sdk.api.file
|
||||||
.list({ location: { directory: absolute } })
|
.list({ location: { directory: absolute } })
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
result.data.map((entry) => ({
|
result.data.map((entry) => ({
|
||||||
|
|||||||
@@ -57,23 +57,21 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
let list: ListRef | undefined
|
let list: ListRef | undefined
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
() =>
|
||||||
if ((await sdk.protocol) === "v1")
|
sdk.api.location
|
||||||
return sdk.legacy.path.get().catch(() => undefined)
|
|
||||||
return sdk.api.location
|
|
||||||
.get()
|
.get()
|
||||||
.then((location) => ({
|
.then(
|
||||||
state: "",
|
(location): Path => ({
|
||||||
config: "",
|
state: "",
|
||||||
worktree: location.project.directory,
|
config: "",
|
||||||
directory: location.directory,
|
worktree: location.project.directory,
|
||||||
home: "",
|
directory: location.directory,
|
||||||
}))
|
home: "",
|
||||||
.catch(() => undefined)
|
}),
|
||||||
},
|
)
|
||||||
|
.catch(() => undefined),
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const statusLabels = {
|
|||||||
connected: "mcp.status.connected",
|
connected: "mcp.status.connected",
|
||||||
failed: "mcp.status.failed",
|
failed: "mcp.status.failed",
|
||||||
needs_auth: "mcp.status.needs_auth",
|
needs_auth: "mcp.status.needs_auth",
|
||||||
needs_client_registration: "mcp.status.needs_client_registration",
|
|
||||||
disabled: "mcp.status.disabled",
|
disabled: "mcp.status.disabled",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
if (s?.status === "failed") return s.error
|
||||||
}
|
}
|
||||||
const enabled = () => status() === "connected"
|
const enabled = () => status() === "connected"
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!settings.general.newLayoutDesigns() &&
|
|
||||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
|
||||||
) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
if (options.navigateOnAdd === false) {
|
if (options.navigateOnAdd === false) {
|
||||||
@@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!settings.general.newLayoutDesigns() &&
|
|
||||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
|
||||||
) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized === input.original.http.url) {
|
if (normalized === input.original.http.url) {
|
||||||
server.add(conn)
|
server.add(conn)
|
||||||
} else {
|
} else {
|
||||||
@@ -360,9 +345,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const raw = items()
|
const raw = items()
|
||||||
const list = settings.general.newLayoutDesigns()
|
const list = raw
|
||||||
? raw
|
|
||||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { Component, createSignal, startTransition } from "solid-js"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { SettingsGeneral } from "./settings-general"
|
|
||||||
import { SettingsKeybinds } from "./settings-keybinds"
|
|
||||||
import { SettingsProviders } from "./settings-providers"
|
|
||||||
import { SettingsModels } from "./settings-models"
|
|
||||||
import { SettingsServers } from "./settings-servers"
|
|
||||||
|
|
||||||
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
|
||||||
|
|
||||||
const showProviders = () => {
|
|
||||||
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog size="x-large" transition>
|
|
||||||
<Tabs
|
|
||||||
orientation="vertical"
|
|
||||||
variant="settings"
|
|
||||||
value={tab()}
|
|
||||||
onChange={(value) => void startTransition(() => setTab(value))}
|
|
||||||
class="h-full settings-dialog"
|
|
||||||
>
|
|
||||||
<Tabs.List>
|
|
||||||
<div class="flex flex-col justify-between h-full w-full gap-4">
|
|
||||||
<div class="flex flex-col gap-3 w-full pt-3">
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
|
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
|
||||||
<Tabs.Trigger value="general">
|
|
||||||
<Icon name="sliders" />
|
|
||||||
{language.t("settings.tab.general")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="shortcuts">
|
|
||||||
<Icon name="keyboard" />
|
|
||||||
{language.t("settings.tab.shortcuts")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="servers">
|
|
||||||
<Icon name="server" />
|
|
||||||
{language.t("status.popover.tab.servers")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
|
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
|
||||||
<Tabs.Trigger value="providers">
|
|
||||||
<Icon name="providers" />
|
|
||||||
{language.t("settings.providers.title")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="models">
|
|
||||||
<Icon name="models" />
|
|
||||||
{language.t("settings.models.title")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
|
|
||||||
<span>{language.t("app.name.desktop")}</span>
|
|
||||||
<span class="text-11-regular">v{platform.version}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.List>
|
|
||||||
<Tabs.Content value="general" class="no-scrollbar">
|
|
||||||
<SettingsGeneral />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
|
||||||
<SettingsKeybinds />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="servers" class="no-scrollbar">
|
|
||||||
<SettingsServers />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="providers" class="no-scrollbar">
|
|
||||||
<SettingsProviders onBack={showProviders} />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="models" class="no-scrollbar">
|
|
||||||
<SettingsModels />
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -133,7 +133,7 @@ test("scopes file autocomplete to the current browser root", () => {
|
|||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
currentApi: {
|
api: {
|
||||||
file: {
|
file: {
|
||||||
find: (input: { location?: { directory?: string } }) => {
|
find: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.location?.directory ?? "")
|
directories.push(input.location?.directory ?? "")
|
||||||
@@ -155,7 +155,7 @@ test("resolves directory autocomplete from the current browser root", async () =
|
|||||||
test("searches from an absolute root without a default base", async () => {
|
test("searches from an absolute root without a default base", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
currentApi: {
|
api: {
|
||||||
file: {
|
file: {
|
||||||
list: (input: { location?: { directory?: string } }) => {
|
list: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.location?.directory ?? "")
|
directories.push(input.location?.directory ?? "")
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.currentApi.file
|
const request = args.sdk.api.file
|
||||||
.list({ location: { directory: key } })
|
.list({ location: { directory: key } })
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -374,7 +374,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
if (!pathInput) {
|
||||||
const results = await args.sdk.currentApi.file
|
const results = await args.sdk.api.file
|
||||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||||
.then((result) => result.data.map((entry) => entry.path))
|
.then((result) => result.data.map((entry) => entry.path))
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { type LocalProject } from "@/context/layout"
|
|||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const supported = !props.project.id || props.project.id === "global"
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||||
@@ -71,23 +72,9 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
if ((await serverCtx().sdk.protocol) !== "v1") return
|
// TODO: Restore project edits when the V2 client exposes a project update API.
|
||||||
const project = await serverCtx()
|
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
|
||||||
.sdk.legacy.project.update({
|
throw new Error(`Project ${props.project.id} cannot be updated`)
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
.then((result) => result.data)
|
|
||||||
if (!project) return
|
|
||||||
serverCtx().sync.set("project", (items) =>
|
|
||||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
|
||||||
)
|
|
||||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serverCtx().sync.project.meta(props.project.worktree, {
|
serverCtx().sync.project.meta(props.project.worktree, {
|
||||||
@@ -101,7 +88,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function submit(event: SubmitEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (save.isPending) return
|
if (!supported || save.isPending) return
|
||||||
save.mutate()
|
save.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +98,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
folderName,
|
folderName,
|
||||||
defaultName,
|
defaultName,
|
||||||
save,
|
save,
|
||||||
|
supported,
|
||||||
submit,
|
submit,
|
||||||
drop,
|
drop,
|
||||||
dragOver,
|
dragOver,
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
||||||
import { createSignal, Show } from "solid-js"
|
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
|
||||||
import homeImage from "@/assets/help/home.png"
|
|
||||||
import tabsImage from "@/assets/help/tabs.png"
|
|
||||||
|
|
||||||
// TODO: wire to changelog / seen-state when available
|
|
||||||
const showPopover = () => true
|
|
||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
|
||||||
export function TabsInfoPopup() {
|
|
||||||
const settings = useSettings()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
|
||||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
|
||||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
|
||||||
<div
|
|
||||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
|
||||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Dismiss Tabs information"
|
|
||||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
|
||||||
onClick={settings.general.dismissTabsToast}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
|
||||||
onClick={() => {
|
|
||||||
settings.general.dismissTabsToast()
|
|
||||||
setDrawerOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
src={introducingTabsVideo}
|
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
autoplay
|
|
||||||
playsinline
|
|
||||||
aria-hidden="true"
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
/>
|
|
||||||
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
|
||||||
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
|
||||||
Introducing Tabs
|
|
||||||
</p>
|
|
||||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
|
||||||
Organize your work and active sessions with tabs
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<DrawerContent
|
|
||||||
style={
|
|
||||||
windows()
|
|
||||||
? {
|
|
||||||
inset: "0 0 0 auto",
|
|
||||||
"max-height": "100vh",
|
|
||||||
"max-width": "100vw",
|
|
||||||
"border-radius": "0",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={windows()}>
|
|
||||||
<DrawerClose
|
|
||||||
as={IconButtonV2}
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
variant="neutral"
|
|
||||||
aria-label="Close"
|
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
class="absolute top-[10px] left-[-36px]"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div
|
|
||||||
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
|
||||||
classList={{
|
|
||||||
"h-[40px] px-4": windows(),
|
|
||||||
"h-[52px] p-4": !windows(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
|
||||||
July 14
|
|
||||||
</p>
|
|
||||||
<Show when={!windows()}>
|
|
||||||
<DrawerClose
|
|
||||||
as={IconButtonV2}
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
variant="ghost-muted"
|
|
||||||
aria-label="Close"
|
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
|
||||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
|
||||||
Introducing Tabs
|
|
||||||
</p>
|
|
||||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
<p>OpenCode Desktop is now built around tabs.</p>
|
|
||||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
|
||||||
<p>
|
|
||||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
|
||||||
you're starting something new, and close it when you're done.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
|
||||||
memorable if you plan to keep them around.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
|
||||||
</p>
|
|
||||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
|
||||||
<p>When you reopen the app, your tabs are still open.</p>
|
|
||||||
<p>
|
|
||||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
|
||||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
|
||||||
will become permanent in a few weeks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import type { ReferenceInfo } from "@/types"
|
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
|
|||||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import type { ReferenceInfo } from "@/types"
|
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export { createPromptInputHistory }
|
export { createPromptInputHistory }
|
||||||
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types"
|
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { encodeFilePath } from "@/context/file/path"
|
import { encodeFilePath } from "@/context/file/path"
|
||||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||||
|
|
||||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
type PromptRequestPart =
|
||||||
|
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||||
|
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||||
|
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||||
|
|
||||||
type ContextFile = {
|
type ContextFile = {
|
||||||
key: string
|
key: string
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string
|
|||||||
const sentPrompts: string[] = []
|
const sentPrompts: string[] = []
|
||||||
const promptInputs: unknown[] = []
|
const promptInputs: unknown[] = []
|
||||||
const sentCommands: unknown[] = []
|
const sentCommands: unknown[] = []
|
||||||
|
const switchedAgents: Array<{ sessionID: string; agent: string }> = []
|
||||||
|
const switchedModels: Array<{
|
||||||
|
sessionID: string
|
||||||
|
model: { id: string; providerID: string; variant?: string }
|
||||||
|
}> = []
|
||||||
|
const sessionRequestOrder: string[] = []
|
||||||
const commands: Array<{ name: string }> = []
|
const commands: Array<{ name: string }> = []
|
||||||
let serverSessionSyncs = 0
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
@@ -93,10 +99,22 @@ const clientFor = (directory: string) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
prompt: async (input: unknown) => {
|
prompt: async (input: unknown) => {
|
||||||
|
sessionRequestOrder.push("prompt")
|
||||||
sentPrompts.push(directory)
|
sentPrompts.push(directory)
|
||||||
promptInputs.push(input)
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
|
switchAgent: async (input: { sessionID: string; agent: string }) => {
|
||||||
|
sessionRequestOrder.push("agent")
|
||||||
|
switchedAgents.push(input)
|
||||||
|
},
|
||||||
|
switchModel: async (input: {
|
||||||
|
sessionID: string
|
||||||
|
model: { id: string; providerID: string; variant?: string }
|
||||||
|
}) => {
|
||||||
|
sessionRequestOrder.push("model")
|
||||||
|
switchedModels.push(input)
|
||||||
|
},
|
||||||
command: async (input: unknown) => {
|
command: async (input: unknown) => {
|
||||||
sentCommands.push(input)
|
sentCommands.push(input)
|
||||||
},
|
},
|
||||||
@@ -125,13 +143,6 @@ beforeAll(async () => {
|
|||||||
useSearchParams: () => [search, () => undefined],
|
useSearchParams: () => [search, () => undefined],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
|
||||||
createOpencodeClient: (input: { directory: string }) => {
|
|
||||||
createdClients.push(input.directory)
|
|
||||||
return clientFor(input.directory)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("@opencode-ai/ui/toast", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
Toast: { Region: () => null },
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
@@ -197,13 +208,8 @@ beforeAll(async () => {
|
|||||||
const sdk = {
|
const sdk = {
|
||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
client: rootClient,
|
|
||||||
api: rootClient.api,
|
api: rootClient.api,
|
||||||
currentApi: rootClient.api,
|
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
|
||||||
return clientFor(opts.directory)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return () => sdk
|
return () => sdk
|
||||||
},
|
},
|
||||||
@@ -291,6 +297,9 @@ beforeEach(() => {
|
|||||||
sentPrompts.length = 0
|
sentPrompts.length = 0
|
||||||
promptInputs.length = 0
|
promptInputs.length = 0
|
||||||
sentCommands.length = 0
|
sentCommands.length = 0
|
||||||
|
switchedAgents.length = 0
|
||||||
|
switchedModels.length = 0
|
||||||
|
sessionRequestOrder.length = 0
|
||||||
commands.length = 0
|
commands.length = 0
|
||||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
params = {}
|
params = {}
|
||||||
@@ -448,13 +457,17 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("includes the selected variant on optimistic prompts", async () => {
|
test("switches the selected agent and model before prompting", async () => {
|
||||||
params = { id: "session-1" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
prompt,
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({
|
||||||
|
id: "session-1",
|
||||||
|
agent: "old-agent",
|
||||||
|
model: { id: "old-model", providerID: "old-provider" },
|
||||||
|
}),
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
autoAccept: () => false,
|
autoAccept: () => false,
|
||||||
@@ -483,6 +496,14 @@ describe("prompt submit worktree selection", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(sentPrompts).toEqual(["/repo/main"])
|
expect(sentPrompts).toEqual(["/repo/main"])
|
||||||
|
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||||
|
expect(switchedModels).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: "session-1",
|
||||||
|
model: { id: "model", providerID: "provider", variant: "high" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(sessionRequestOrder).toEqual(["agent", "model", "prompt"])
|
||||||
expect(promptInputs[0]).toMatchObject({
|
expect(promptInputs[0]).toMatchObject({
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
text: "ls",
|
text: "ls",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Message, Session } from "@/types"
|
import type { Message } from "@/types"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
@@ -15,14 +16,13 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
|||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
|
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -42,9 +42,10 @@ export type FollowupDraft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
api: DirectorySDK["currentApi"]["session"]
|
api: DirectorySDK["api"]["session"]
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
|
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
optimisticBusy?: boolean
|
optimisticBusy?: boolean
|
||||||
@@ -157,6 +158,25 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = input.session()
|
||||||
|
if (session?.agent !== input.draft.agent) {
|
||||||
|
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
session?.model?.providerID !== input.draft.model.providerID ||
|
||||||
|
session.model.id !== input.draft.model.modelID ||
|
||||||
|
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
|
||||||
|
) {
|
||||||
|
await input.api.switchModel({
|
||||||
|
sessionID: input.draft.sessionID,
|
||||||
|
model: {
|
||||||
|
id: input.draft.model.modelID,
|
||||||
|
providerID: input.draft.model.providerID,
|
||||||
|
variant: input.draft.variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await input.api.prompt({
|
await input.api.prompt({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
id: messageID,
|
id: messageID,
|
||||||
@@ -197,7 +217,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
|
|
||||||
type PromptSubmitInput = {
|
type PromptSubmitInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
prompt: ReturnType<typeof usePrompt>
|
||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<
|
||||||
|
{ id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined
|
||||||
|
>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
commentCount: Accessor<number>
|
commentCount: Accessor<number>
|
||||||
autoAccept: Accessor<boolean>
|
autoAccept: Accessor<boolean>
|
||||||
@@ -261,7 +283,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.currentApi.session.interrupt({ sessionID })
|
.api.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,10 +310,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: Session) => {
|
const seed = (dir: string, info: SessionInfo) => {
|
||||||
serverSync().session.remember(info)
|
serverSync().session.remember(info)
|
||||||
const [, setStore] = serverSync().child(dir)
|
const [, setStore] = serverSync().child(dir)
|
||||||
setStore("session", (list: Session[]) => {
|
setStore("session", (list: SessionInfo[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
@@ -345,11 +367,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
|
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
if (worktreeSelection === "create") {
|
if (worktreeSelection === "create") {
|
||||||
const createdWorktree = await sdk()
|
const createdWorktree = await sdk()
|
||||||
.currentApi.projectCopy.create({
|
.api.projectCopy.create({
|
||||||
projectID: sync().data.project,
|
projectID: sync().data.project,
|
||||||
strategy: "git_worktree",
|
strategy: "git_worktree",
|
||||||
directory: getDirectory(projectDirectory),
|
directory: getDirectory(projectDirectory),
|
||||||
@@ -362,7 +383,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!createdWorktree) return
|
if (!createdWorktree) return
|
||||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||||
sessionDirectory = createdWorktree.directory
|
sessionDirectory = createdWorktree.directory
|
||||||
@@ -382,12 +402,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
let session = input.info()
|
let session = input.info()
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
const created = await sdk()
|
const created = await sdk()
|
||||||
.currentApi.session.create({
|
.api.session.create({
|
||||||
agent: currentAgent.name,
|
agent: currentAgent.name,
|
||||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
location: { directory: sessionDirectory },
|
location: { directory: sessionDirectory },
|
||||||
})
|
})
|
||||||
.then(normalizeSessionInfo)
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
@@ -473,7 +492,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
clearInput()
|
clearInput()
|
||||||
const eventID = Event.ID.create()
|
const eventID = Event.ID.create()
|
||||||
sdk()
|
sdk()
|
||||||
.currentApi.session.shell({
|
.api.session.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: eventID,
|
id: eventID,
|
||||||
command: text,
|
command: text,
|
||||||
@@ -497,7 +516,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||||
sdk()
|
sdk()
|
||||||
.currentApi.session.command({
|
.api.session.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: messageID,
|
id: messageID,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
@@ -594,9 +613,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
api: sdk().currentApi.session,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
|
session: () => input.info() ?? session,
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
|||||||
@@ -1,792 +0,0 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useUpdaterAction } from "./updater-action"
|
|
||||||
import {
|
|
||||||
monoDefault,
|
|
||||||
monoFontFamily,
|
|
||||||
monoInput,
|
|
||||||
sansDefault,
|
|
||||||
sansFontFamily,
|
|
||||||
sansInput,
|
|
||||||
terminalDefault,
|
|
||||||
terminalFontFamily,
|
|
||||||
terminalInput,
|
|
||||||
useSettings,
|
|
||||||
} from "@/context/settings"
|
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
|
||||||
import { Link } from "./link"
|
|
||||||
import { SettingsList } from "./settings-list"
|
|
||||||
|
|
||||||
let demoSoundState = {
|
|
||||||
cleanup: undefined as (() => void) | undefined,
|
|
||||||
timeout: undefined as NodeJS.Timeout | undefined,
|
|
||||||
run: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
type ThemeOption = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShellOption = {
|
|
||||||
path: string
|
|
||||||
name: string
|
|
||||||
acceptable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShellSelectOption = {
|
|
||||||
id: string
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
|
||||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
|
||||||
const stopDemoSound = () => {
|
|
||||||
demoSoundState.run += 1
|
|
||||||
if (demoSoundState.cleanup) {
|
|
||||||
demoSoundState.cleanup()
|
|
||||||
}
|
|
||||||
clearTimeout(demoSoundState.timeout)
|
|
||||||
demoSoundState.cleanup = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const playDemoSound = (id: string | undefined) => {
|
|
||||||
stopDemoSound()
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
const run = ++demoSoundState.run
|
|
||||||
demoSoundState.timeout = setTimeout(() => {
|
|
||||||
void playSoundById(id).then((cleanup) => {
|
|
||||||
if (demoSoundState.run !== run) {
|
|
||||||
cleanup?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
demoSoundState.cleanup = cleanup
|
|
||||||
})
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsGeneral: Component = () => {
|
|
||||||
const theme = useTheme()
|
|
||||||
const language = useLanguage()
|
|
||||||
const permission = usePermission()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const params = useParams()
|
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return false
|
|
||||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
|
||||||
return permission.isAutoAccepting(params.id, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleAccept = (checked: boolean) => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return
|
|
||||||
|
|
||||||
if (!params.id) {
|
|
||||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
|
||||||
permission.toggleAutoAcceptDirectory(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
permission.enableAutoAccept(params.id, value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
permission.disableAutoAccept(params.id, value)
|
|
||||||
}
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
|
||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
|
||||||
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const protocol = useServerProtocol()
|
|
||||||
|
|
||||||
const [shells] = createResource(
|
|
||||||
() => (protocol() === "v1" ? serverSdk() : undefined),
|
|
||||||
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
|
||||||
)
|
|
||||||
|
|
||||||
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
|
|
||||||
() => (linux() && platform.getDisplayBackend ? true : false),
|
|
||||||
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
|
|
||||||
{ initialValue: null as DisplayBackend | null },
|
|
||||||
)
|
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
|
||||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
|
||||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
|
||||||
{ initialValue: false },
|
|
||||||
)
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void theme.loadThemes()
|
|
||||||
})
|
|
||||||
|
|
||||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
|
||||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
|
||||||
|
|
||||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
|
||||||
const list = shells.latest
|
|
||||||
const current = serverSync().data.config.shell
|
|
||||||
|
|
||||||
const nameCounts = new Map<string, number>()
|
|
||||||
for (const s of list) {
|
|
||||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
autoOption,
|
|
||||||
...list.map((s) => {
|
|
||||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
|
||||||
const text = ambiguousName ? s.path : s.name
|
|
||||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
|
||||||
return {
|
|
||||||
id: s.path,
|
|
||||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
|
||||||
value: ambiguousName ? s.path : s.name,
|
|
||||||
label,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (current && !options.some((o) => o.value === current)) {
|
|
||||||
options.push({ id: current, value: current, label: current })
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
})
|
|
||||||
|
|
||||||
const onDisplayBackendChange = (checked: boolean) => {
|
|
||||||
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
|
|
||||||
if (!update) return
|
|
||||||
void update.finally(() => {
|
|
||||||
void refetchDisplayBackend()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPinchZoomChange = (checked: boolean) => {
|
|
||||||
setPinchZoom(checked)
|
|
||||||
const update = platform.setPinchZoomEnabled?.(checked)
|
|
||||||
if (!update) return
|
|
||||||
void update.catch(() => setPinchZoom(!checked))
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
|
||||||
{ value: "system", label: language.t("theme.scheme.system") },
|
|
||||||
{ value: "light", label: language.t("theme.scheme.light") },
|
|
||||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
|
||||||
])
|
|
||||||
|
|
||||||
const languageOptions = createMemo(() =>
|
|
||||||
language.locales.map((locale) => ({
|
|
||||||
value: locale,
|
|
||||||
label: language.label(locale),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
|
||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
|
||||||
const mono = () => monoInput(settings.appearance.font())
|
|
||||||
const sans = () => sansInput(settings.appearance.uiFont())
|
|
||||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
|
||||||
|
|
||||||
const soundSelectProps = (
|
|
||||||
enabled: () => boolean,
|
|
||||||
current: () => string,
|
|
||||||
setEnabled: (value: boolean) => void,
|
|
||||||
set: (id: string) => void,
|
|
||||||
) => ({
|
|
||||||
options: soundOptions,
|
|
||||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
|
||||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
|
||||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
|
||||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
|
||||||
if (!option) return
|
|
||||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
|
||||||
},
|
|
||||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
|
||||||
if (!option) return
|
|
||||||
if (option.id === "none") {
|
|
||||||
setEnabled(false)
|
|
||||||
stopDemoSound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEnabled(true)
|
|
||||||
set(option.id)
|
|
||||||
playDemoSound(option.id)
|
|
||||||
},
|
|
||||||
variant: "secondary" as const,
|
|
||||||
size: "small" as const,
|
|
||||||
triggerVariant: "settings" as const,
|
|
||||||
})
|
|
||||||
|
|
||||||
const InterfaceSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
{language.t("settings.general.row.newInterface.title")}
|
|
||||||
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
description={language.t("settings.general.row.newInterface.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-new-layout-designs">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.newLayoutDesigns()}
|
|
||||||
onChange={(checked) => {
|
|
||||||
settings.general.setNewLayoutDesigns(checked)
|
|
||||||
if (!checked) return
|
|
||||||
void import("@/components/settings-v2").then((module) => {
|
|
||||||
void dialog.show(() => <module.DialogSettings />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const InterfaceNoticeSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
|
||||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
|
||||||
>
|
|
||||||
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
|
||||||
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
|
||||||
</Button>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const GeneralSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.language.title")}
|
|
||||||
description={language.t("settings.general.row.language.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-language"
|
|
||||||
options={languageOptions()}
|
|
||||||
current={languageOptions().find((o) => o.value === language.locale())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && language.setLocale(option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
|
||||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-auto-accept-permissions">
|
|
||||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<Show when={protocol() === "v1"}>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.shell.title")}
|
|
||||||
description={language.t("settings.general.row.shell.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-shell"
|
|
||||||
options={shellOptions()}
|
|
||||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
if (option.value === currentShell()) return
|
|
||||||
serverSync().updateConfig({ shell: option.value })
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
triggerStyle={{ "min-width": "180px" }}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
|
||||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-reasoning-summaries">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showReasoningSummaries()}
|
|
||||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
|
||||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.shellToolPartsExpanded()}
|
|
||||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
|
||||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.editToolPartsExpanded()}
|
|
||||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const AdvancedSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showFileTree.title")}
|
|
||||||
description={language.t("settings.general.row.showFileTree.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-file-tree">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showFileTree()}
|
|
||||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showNavigation.title")}
|
|
||||||
description={language.t("settings.general.row.showNavigation.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-navigation">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showNavigation()}
|
|
||||||
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showSearch.title")}
|
|
||||||
description={language.t("settings.general.row.showSearch.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-search">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showSearch()}
|
|
||||||
onChange={(checked) => settings.general.setShowSearch(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showStatus.title")}
|
|
||||||
description={language.t("settings.general.row.showStatus.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-status">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showStatus()}
|
|
||||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showCustomAgents.title")}
|
|
||||||
description={language.t("settings.general.row.showCustomAgents.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-custom-agents">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showCustomAgents()}
|
|
||||||
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const AppearanceSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.colorScheme.title")}
|
|
||||||
description={language.t("settings.general.row.colorScheme.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-color-scheme"
|
|
||||||
options={colorSchemeOptions()}
|
|
||||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
triggerStyle={{ "min-width": "220px" }}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={themeOptions()}
|
|
||||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => o.name}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
theme.setTheme(option.id)
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.uiFont.title")}
|
|
||||||
description={language.t("settings.general.row.uiFont.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextField
|
|
||||||
data-action="settings-ui-font"
|
|
||||||
label={language.t("settings.general.row.uiFont.title")}
|
|
||||||
hideLabel
|
|
||||||
type="text"
|
|
||||||
value={sans()}
|
|
||||||
onChange={(value) => settings.appearance.setUIFont(value)}
|
|
||||||
placeholder={sansDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="text-12-regular"
|
|
||||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.font.title")}
|
|
||||||
description={language.t("settings.general.row.font.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextField
|
|
||||||
data-action="settings-code-font"
|
|
||||||
label={language.t("settings.general.row.font.title")}
|
|
||||||
hideLabel
|
|
||||||
type="text"
|
|
||||||
value={mono()}
|
|
||||||
onChange={(value) => settings.appearance.setFont(value)}
|
|
||||||
placeholder={monoDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="text-12-regular"
|
|
||||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.terminalFont.title")}
|
|
||||||
description={language.t("settings.general.row.terminalFont.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextField
|
|
||||||
data-action="settings-terminal-font"
|
|
||||||
label={language.t("settings.general.row.terminalFont.title")}
|
|
||||||
hideLabel
|
|
||||||
type="text"
|
|
||||||
value={terminal()}
|
|
||||||
onChange={(value) => settings.appearance.setTerminalFont(value)}
|
|
||||||
placeholder={terminalDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="text-12-regular"
|
|
||||||
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const NotificationsSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.agent.title")}
|
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-agent">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.agent()}
|
|
||||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.permissions.title")}
|
|
||||||
description={language.t("settings.general.notifications.permissions.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-permissions">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.permissions()}
|
|
||||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.errors.title")}
|
|
||||||
description={language.t("settings.general.notifications.errors.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-errors">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.errors()}
|
|
||||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SoundsSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.agent.title")}
|
|
||||||
description={language.t("settings.general.sounds.agent.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-agent"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.agentEnabled(),
|
|
||||||
() => settings.sounds.agent(),
|
|
||||||
(value) => settings.sounds.setAgentEnabled(value),
|
|
||||||
(id) => settings.sounds.setAgent(id),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.permissions.title")}
|
|
||||||
description={language.t("settings.general.sounds.permissions.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-permissions"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.permissionsEnabled(),
|
|
||||||
() => settings.sounds.permissions(),
|
|
||||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
|
||||||
(id) => settings.sounds.setPermissions(id),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.errors.title")}
|
|
||||||
description={language.t("settings.general.sounds.errors.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-errors"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.errorsEnabled(),
|
|
||||||
() => settings.sounds.errors(),
|
|
||||||
(value) => settings.sounds.setErrorsEnabled(value),
|
|
||||||
(id) => settings.sounds.setErrors(id),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const UpdatesSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.releaseNotes.title")}
|
|
||||||
description={language.t("settings.general.row.releaseNotes.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-release-notes">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.releaseNotes()}
|
|
||||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.updates.row.check.title")}
|
|
||||||
description={language.t("settings.updates.row.check.description")}
|
|
||||||
>
|
|
||||||
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
|
||||||
{language.t(updater.action().label)}
|
|
||||||
</Button>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const DisplaySection = () => (
|
|
||||||
<Show when={desktop()}>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.pinchZoom.title")}
|
|
||||||
description={language.t("settings.general.row.pinchZoom.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-pinch-zoom">
|
|
||||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<Show when={linux()}>
|
|
||||||
<SettingsRow
|
|
||||||
title={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
|
||||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
|
||||||
<span class="text-text-weak">
|
|
||||||
<Icon name="help" size="small" />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
description={language.t("settings.general.row.wayland.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-wayland">
|
|
||||||
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</Show>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 w-full">
|
|
||||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
|
||||||
<InterfaceSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
|
||||||
<InterfaceNoticeSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<GeneralSection />
|
|
||||||
|
|
||||||
<AppearanceSection />
|
|
||||||
|
|
||||||
<NotificationsSection />
|
|
||||||
|
|
||||||
<SoundsSection />
|
|
||||||
|
|
||||||
<UpdatesSection />
|
|
||||||
|
|
||||||
<DisplaySection />
|
|
||||||
|
|
||||||
<Show when={desktop()}>
|
|
||||||
<AdvancedSection />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsRowProps {
|
|
||||||
title: string | JSX.Element
|
|
||||||
description: string | JSX.Element
|
|
||||||
children: JSX.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
|
||||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { type Component, For, Show } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useModels } from "@/context/models"
|
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
|
||||||
import { SettingsList } from "./settings-list"
|
|
||||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
|
||||||
|
|
||||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
|
||||||
|
|
||||||
const ListLoadingState: Component<{ label: string }> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<span class="text-14-regular text-text-weak">{props.label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<span class="text-14-regular text-text-weak">{props.message}</span>
|
|
||||||
<Show when={props.filter}>
|
|
||||||
<span class="text-14-regular text-text-strong mt-1">"{props.filter}"</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsModels: Component = () => {
|
|
||||||
return (
|
|
||||||
<SettingsServerScope>
|
|
||||||
<SettingsModelsContent />
|
|
||||||
</SettingsServerScope>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsModelsContent: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const models = useModels()
|
|
||||||
|
|
||||||
const list = useFilteredList<ModelItem>({
|
|
||||||
items: (_filter) => models.list(),
|
|
||||||
key: (x) => `${x.provider.id}:${x.id}`,
|
|
||||||
filterKeys: ["provider.name", "name", "id"],
|
|
||||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
|
||||||
groupBy: (x) => x.provider.id,
|
|
||||||
sortGroupsBy: (a, b) => {
|
|
||||||
const aIndex = popularProviders.indexOf(a.category)
|
|
||||||
const bIndex = popularProviders.indexOf(b.category)
|
|
||||||
const aPopular = aIndex >= 0
|
|
||||||
const bPopular = bIndex >= 0
|
|
||||||
|
|
||||||
if (aPopular && !bPopular) return -1
|
|
||||||
if (!aPopular && bPopular) return 1
|
|
||||||
if (aPopular && bPopular) return aIndex - bIndex
|
|
||||||
|
|
||||||
const aName = a.items[0].provider.name
|
|
||||||
const bName = b.items[0].provider.name
|
|
||||||
return aName.localeCompare(bName)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
|
||||||
<SettingsServerPicker />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
|
||||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
|
||||||
<TextField
|
|
||||||
variant="ghost"
|
|
||||||
type="text"
|
|
||||||
value={list.filter()}
|
|
||||||
onChange={list.onInput}
|
|
||||||
placeholder={language.t("dialog.model.search.placeholder")}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Show when={list.filter()}>
|
|
||||||
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
|
||||||
<Show
|
|
||||||
when={!list.grouped.loading}
|
|
||||||
fallback={
|
|
||||||
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={list.flat().length > 0}
|
|
||||||
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
|
|
||||||
>
|
|
||||||
<For each={list.grouped.latest}>
|
|
||||||
{(group) => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="flex items-center gap-2 pb-2">
|
|
||||||
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
|
||||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
|
||||||
</div>
|
|
||||||
<SettingsList>
|
|
||||||
<For each={group.items}>
|
|
||||||
{(item) => {
|
|
||||||
const key = { providerID: item.provider.id, modelID: item.id }
|
|
||||||
return (
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<Switch
|
|
||||||
checked={models.visible(key)}
|
|
||||||
onChange={(checked) => {
|
|
||||||
models.setVisibility(key, checked)
|
|
||||||
}}
|
|
||||||
hideLabel
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user