Merge branch 'dev' into kit/dev-memory-observe
This commit is contained in:
commit
4b45a3d368
93 changed files with 63420 additions and 2558 deletions
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
|
|
@ -47,6 +47,11 @@ jobs:
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun turbo test
|
run: bun turbo test
|
||||||
|
env:
|
||||||
|
# Bun 1.3.11 intermittently crashes on Windows during test teardown
|
||||||
|
# inside the native @parcel/watcher binding. Unit CI does not rely on
|
||||||
|
# the live watcher backend there, so disable it for that platform.
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
|
|
|
||||||
65
bun.lock
65
bun.lock
|
|
@ -231,6 +231,7 @@
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "^10",
|
"electron-store": "^10",
|
||||||
"electron-updater": "^6",
|
"electron-updater": "^6",
|
||||||
|
|
@ -305,7 +306,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.14.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
"@ai-sdk/amazon-bedrock": "4.0.83",
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
|
|
@ -679,7 +680,7 @@
|
||||||
|
|
||||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||||
|
|
||||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
|
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
||||||
|
|
||||||
|
|
@ -2621,7 +2622,7 @@
|
||||||
|
|
||||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||||
|
|
||||||
"cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||||
|
|
||||||
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
|
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
|
||||||
|
|
||||||
|
|
@ -2851,6 +2852,12 @@
|
||||||
|
|
||||||
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="],
|
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="],
|
||||||
|
|
||||||
|
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
|
||||||
|
|
||||||
|
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
|
||||||
|
|
||||||
|
"electron-is-dev": ["electron-is-dev@3.0.1", "", {}, "sha512-8TjjAh8Ec51hUi3o4TaU0mD3GMTOESi866oRNavj9A3IQJ7pmv+MJVmdZBFGw4GFT36X7bkqnuDNYvkQgvyI8Q=="],
|
||||||
|
|
||||||
"electron-log": ["electron-log@5.4.3", "", {}, "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ=="],
|
"electron-log": ["electron-log@5.4.3", "", {}, "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ=="],
|
||||||
|
|
||||||
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
|
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
|
||||||
|
|
@ -2925,9 +2932,11 @@
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="],
|
||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||||
|
|
||||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||||
|
|
||||||
|
|
@ -2977,6 +2986,10 @@
|
||||||
|
|
||||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||||
|
|
||||||
|
"ext-list": ["ext-list@2.2.2", "", { "dependencies": { "mime-db": "^1.28.0" } }, "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA=="],
|
||||||
|
|
||||||
|
"ext-name": ["ext-name@5.0.0", "", { "dependencies": { "ext-list": "^2.0.0", "sort-keys-length": "^1.0.0" } }, "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ=="],
|
||||||
|
|
||||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||||
|
|
@ -3975,7 +3988,7 @@
|
||||||
|
|
||||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||||
|
|
||||||
"path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="],
|
"path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="],
|
||||||
|
|
||||||
|
|
@ -4111,6 +4124,8 @@
|
||||||
|
|
||||||
"punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
|
"punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
|
||||||
|
|
||||||
|
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
|
||||||
|
|
||||||
"pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
|
"pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
|
||||||
|
|
||||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||||
|
|
@ -4377,7 +4392,7 @@
|
||||||
|
|
||||||
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
"slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
"slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
|
||||||
|
|
||||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||||
|
|
||||||
|
|
@ -4407,6 +4422,10 @@
|
||||||
|
|
||||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||||
|
|
||||||
|
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
|
||||||
|
|
||||||
|
"sort-keys-length": ["sort-keys-length@1.0.1", "", { "dependencies": { "sort-keys": "^1.0.0" } }, "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
@ -4729,6 +4748,8 @@
|
||||||
|
|
||||||
"unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="],
|
"unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="],
|
||||||
|
|
||||||
|
"unused-filename": ["unused-filename@4.0.1", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "path-exists": "^5.0.0" } }, "sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A=="],
|
||||||
|
|
||||||
"unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="],
|
"unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="],
|
||||||
|
|
||||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
@ -5479,8 +5500,6 @@
|
||||||
|
|
||||||
"c12/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
"c12/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||||
|
|
||||||
"cli-truncate/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=="],
|
|
||||||
|
|
||||||
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
||||||
|
|
||||||
"compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
"compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||||
|
|
@ -5557,6 +5576,8 @@
|
||||||
|
|
||||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
|
"find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
"fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||||
|
|
@ -5581,6 +5602,8 @@
|
||||||
|
|
||||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
|
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
||||||
|
|
||||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||||
|
|
||||||
"ignore-walk/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"ignore-walk/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
@ -5605,8 +5628,6 @@
|
||||||
|
|
||||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||||
|
|
||||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
|
||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||||
|
|
@ -5675,6 +5696,8 @@
|
||||||
|
|
||||||
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||||
|
|
||||||
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||||
|
|
@ -5719,6 +5742,12 @@
|
||||||
|
|
||||||
"sitemap/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
"sitemap/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||||
|
|
||||||
|
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||||
|
|
||||||
|
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
|
||||||
|
|
||||||
"sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
|
"sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
|
||||||
|
|
||||||
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||||
|
|
@ -6293,10 +6322,6 @@
|
||||||
|
|
||||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||||
|
|
||||||
"cli-truncate/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
|
||||||
|
|
||||||
"cli-truncate/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
|
||||||
|
|
||||||
"crc/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"crc/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
@ -6327,6 +6352,10 @@
|
||||||
|
|
||||||
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
|
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||||
|
|
||||||
|
"iconv-corefoundation/cli-truncate/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=="],
|
||||||
|
|
||||||
"js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
"js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
"js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||||
|
|
@ -6647,8 +6676,6 @@
|
||||||
|
|
||||||
"babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
"babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
|
||||||
|
|
||||||
"dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
@ -6667,6 +6694,10 @@
|
||||||
|
|
||||||
"gray-matter/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
"gray-matter/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
|
"iconv-corefoundation/cli-truncate/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"iconv-corefoundation/cli-truncate/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"js-beautify/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
"js-beautify/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||||
|
|
||||||
"js-beautify/glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="],
|
"js-beautify/glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="],
|
||||||
|
|
@ -6779,6 +6810,8 @@
|
||||||
|
|
||||||
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"iconv-corefoundation/cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"js-beautify/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
"js-beautify/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
"js-beautify/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
"js-beautify/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-SQVfq41OQdGCgWuWqyqIN6aggL0r3Hzn2hJ9BwPJN+I=",
|
"x86_64-linux": "sha256-cMIblNlBgq3fJonaFywzT/VrusmFhrHThOKa5p6vIlw=",
|
||||||
"aarch64-linux": "sha256-4w/1HhxsTzPFTHNf4JlnKle6Boz1gVTEedWG64T8E/M=",
|
"aarch64-linux": "sha256-ougfUo4oqyyW2fBUK/i8U0//tqEvYnhNhnG2SR0s3B8=",
|
||||||
"aarch64-darwin": "sha256-uMd+pU1u1yqP4OP/9461Tyy3zwwv/llr+rlllLjM98A=",
|
"aarch64-darwin": "sha256-3n0X0GfEydQgbRTmXnFpnQTKFFE9bOjmHXaJpHji4JE=",
|
||||||
"x86_64-darwin": "sha256-BhIW3FPqKkM2vGfCrxXUvj5tarey33Q7dxCuaj5A+yU="
|
"x86_64-darwin": "sha256-8KEV+Gy+UedqW25ene7O3M0aRPk8LdV8bAKrWCNfeLw="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||||
### Using Fixtures
|
### Using Fixtures
|
||||||
|
|
||||||
- `page` - Playwright page
|
- `page` - Playwright page
|
||||||
- `sdk` - OpenCode SDK client for API calls
|
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
|
||||||
- `gotoSession(sessionID?)` - Navigate to session
|
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
|
||||||
|
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
|
||||||
|
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
|
||||||
|
|
||||||
### Helper Functions
|
### Helper Functions
|
||||||
|
|
||||||
|
|
@ -73,12 +75,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
- `withProject(...)` - Create temp project/workspace
|
|
||||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
|
||||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
|
|
@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
- Prefer `withSession(...)` for temp sessions
|
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
|
||||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
|
||||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
|
||||||
- Avoid calling `sdk.session.delete(...)` directly
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
### Timeouts
|
### Timeouts
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { expect, type Locator, type Page, type Route } from "@playwright/test"
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
|
@ -7,7 +7,6 @@ import { execSync } from "node:child_process"
|
||||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectSwitchSelector,
|
projectSwitchSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
|
|
@ -43,27 +42,6 @@ export async function defocus(page: Page) {
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
|
|
||||||
const url = "**/session/*/prompt_async"
|
|
||||||
const route = async (input: Route) => {
|
|
||||||
const body = input.request().postDataJSON()
|
|
||||||
await input.continue({
|
|
||||||
postData: JSON.stringify({ ...body, noReply: true }),
|
|
||||||
headers: {
|
|
||||||
...input.request().headers(),
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.route(url, route)
|
|
||||||
try {
|
|
||||||
return await fn()
|
|
||||||
} finally {
|
|
||||||
await page.unroute(url, route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function terminalID(term: Locator) {
|
async function terminalID(term: Locator) {
|
||||||
const id = await term.getAttribute(terminalAttr)
|
const id = await term.getAttribute(terminalAttr)
|
||||||
if (id) return id
|
if (id) return id
|
||||||
|
|
@ -227,7 +205,7 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
async function isSidebarClosed(page: Page) {
|
||||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
|
|
@ -258,7 +236,7 @@ async function errorBoundaryText(page: Page) {
|
||||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertHealthy(page: Page, context: string) {
|
async function assertHealthy(page: Page, context: string) {
|
||||||
const text = await errorBoundaryText(page)
|
const text = await errorBoundaryText(page)
|
||||||
if (!text) return
|
if (!text) return
|
||||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||||
|
|
@ -333,63 +311,6 @@ export async function openSettings(page: Page) {
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
|
|
||||||
await page.addInitScript(
|
|
||||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
|
||||||
const key = "opencode.global.dat:server"
|
|
||||||
const defaultKey = "opencode.settings.dat:defaultServerUrl"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const parsed = (() => {
|
|
||||||
if (!raw) return undefined
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as unknown
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
|
||||||
const list = Array.isArray(store.list) ? store.list : []
|
|
||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
|
||||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
|
||||||
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
|
||||||
|
|
||||||
const add = (origin: string, directory: string) => {
|
|
||||||
const current = nextProjects[origin]
|
|
||||||
const items = Array.isArray(current) ? current : []
|
|
||||||
const existing = items.filter(
|
|
||||||
(p): p is { worktree: string; expanded?: boolean } =>
|
|
||||||
!!p &&
|
|
||||||
typeof p === "object" &&
|
|
||||||
"worktree" in p &&
|
|
||||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existing.some((p) => p.worktree === directory)) return
|
|
||||||
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = [args.directory, ...args.extra]
|
|
||||||
for (const directory of directories) {
|
|
||||||
add("local", directory)
|
|
||||||
add(args.serverUrl, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
list: nextList,
|
|
||||||
projects: nextProjects,
|
|
||||||
lastProject,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
localStorage.setItem(defaultKey, args.serverUrl)
|
|
||||||
},
|
|
||||||
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTestProject(input?: { serverUrl?: string }) {
|
export async function createTestProject(input?: { serverUrl?: string }) {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
const id = `e2e-${path.basename(root)}`
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
@ -479,7 +400,15 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
|
||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
|
export async function waitSession(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
sessionID?: string
|
||||||
|
serverUrl?: string
|
||||||
|
allowAnySession?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
const target = await resolveDirectory(input.directory, input.serverUrl)
|
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
|
|
@ -491,11 +420,11 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||||
if (!resolved || resolved.directory !== target) return false
|
if (!resolved || resolved.directory !== target) return false
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (input.sessionID && current !== input.sessionID) return false
|
if (input.sessionID && current !== input.sessionID) return false
|
||||||
if (!input.sessionID && current) return false
|
if (!input.sessionID && !input.allowAnySession && current) return false
|
||||||
|
|
||||||
const state = await probeSession(page)
|
const state = await probeSession(page)
|
||||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
if (!input.sessionID && state?.sessionID) return false
|
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
|
||||||
if (state?.dir) {
|
if (state?.dir) {
|
||||||
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
||||||
if (dir !== target) return false
|
if (dir !== target) return false
|
||||||
|
|
@ -602,12 +531,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSharePopover(page: Page) {
|
export async function openSharePopover(page: Page) {
|
||||||
const rightSection = page.locator(titlebarRightSelector)
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
await expect(scroller).toBeVisible()
|
||||||
await expect(shareButton).toBeVisible()
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||||
|
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
const popoverBody = page
|
const popoverBody = page
|
||||||
.locator(popoverBodySelector)
|
.locator('[data-component="popover-content"]')
|
||||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
|
|
@ -617,16 +549,13 @@ export async function openSharePopover(page: Page) {
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
await shareButton.click()
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
await expect(popoverBody).toBeVisible()
|
await menuTrigger.click()
|
||||||
|
await clickMenuItem(menu, /share/i)
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
|
||||||
}
|
}
|
||||||
return { rightSection, popoverBody }
|
return { rightSection: scroller, popoverBody }
|
||||||
}
|
|
||||||
|
|
||||||
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
|
||||||
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickListItem(
|
export async function clickListItem(
|
||||||
|
|
@ -794,40 +723,6 @@ export async function seedSessionQuestion(
|
||||||
return { id: result.id }
|
return { id: result.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedSessionPermission(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
permission: string
|
|
||||||
patterns: string[]
|
|
||||||
description?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one bash tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({
|
|
||||||
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
|
||||||
workdir: "/",
|
|
||||||
description: input.description ?? `seed ${input.permission} permission request`,
|
|
||||||
})}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
|
||||||
return list.find((item) => item.sessionID === input.sessionID)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding permission request")
|
|
||||||
return { id: result.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedSessionTask(
|
export async function seedSessionTask(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
input: {
|
input: {
|
||||||
|
|
@ -886,36 +781,6 @@ export async function seedSessionTask(
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedSessionTodos(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
todos: Array<{ content: string; status: string; priority: string }>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one todowrite tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
const target = JSON.stringify(input.todos)
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
|
||||||
if (JSON.stringify(todos) !== target) return
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding todos")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
const [questions, permissions] = await Promise.all([
|
const [questions, permissions] = await Promise.all([
|
||||||
sdk.question.list().then((x) => x.data ?? []),
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
|
|
@ -1005,30 +870,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
const current = await page
|
const current = () =>
|
||||||
.getByRole("button", { name: "New workspace" })
|
page
|
||||||
.first()
|
.getByRole("button", { name: "New workspace" })
|
||||||
.isVisible()
|
.first()
|
||||||
.then((x) => x)
|
.isVisible()
|
||||||
.catch(() => false)
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
if (current === enabled) return
|
if ((await current()) === enabled) return
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
if ((await current()) === enabled) return
|
||||||
|
}
|
||||||
|
|
||||||
const flip = async (timeout?: number) => {
|
const flip = async (timeout?: number) => {
|
||||||
const menu = await openProjectMenu(page, projectSlug)
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
await expect(toggle).toBeVisible()
|
await expect(toggle).toBeVisible()
|
||||||
return toggle.click({ force: true, timeout })
|
await expect(toggle).toBeEnabled({ timeout: 30_000 })
|
||||||
|
const clicked = await toggle
|
||||||
|
.click({ force: true, timeout })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (clicked) return
|
||||||
|
await toggle.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
}
|
}
|
||||||
|
|
||||||
const flipped = await flip(1500)
|
for (const timeout of [1500, undefined, undefined]) {
|
||||||
.then(() => true)
|
if ((await current()) === enabled) break
|
||||||
.catch(() => false)
|
await flip(timeout)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch(() => undefined)
|
||||||
|
const matched = await expect
|
||||||
|
.poll(current, { timeout: 5_000 })
|
||||||
|
.toBe(enabled)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (matched) break
|
||||||
|
}
|
||||||
|
|
||||||
if (!flipped) await flip()
|
if ((await current()) !== enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
}
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
|
||||||
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ function tail(input: string[]) {
|
||||||
return input.slice(-40).join("")
|
return input.slice(-40).join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startBackend(label: string): Promise<Handle> {
|
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
|
||||||
const port = await freePort()
|
const port = await freePort()
|
||||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
|
@ -80,6 +80,7 @@ export async function startBackend(label: string): Promise<Handle> {
|
||||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||||
OPENCODE_CLIENT: "app",
|
OPENCODE_CLIENT: "app",
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
|
OPENCODE_E2E_LLM_URL: input?.llmUrl,
|
||||||
} satisfies Record<string, string | undefined>
|
} satisfies Record<string, string | undefined>
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
const err: string[] = []
|
const err: string[] = []
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ import {
|
||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
createTestProject,
|
createTestProject,
|
||||||
setHealthPhase,
|
setHealthPhase,
|
||||||
seedProjects,
|
|
||||||
sessionIDFromUrl,
|
sessionIDFromUrl,
|
||||||
waitSlug,
|
|
||||||
waitSession,
|
waitSession,
|
||||||
|
waitSessionIdle,
|
||||||
|
waitSessionSaved,
|
||||||
|
waitSlug,
|
||||||
} from "./actions"
|
} from "./actions"
|
||||||
import { openaiModel, withMockOpenAI } from "./prompt/mock"
|
import { promptSelector } from "./selectors"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
|
||||||
|
|
||||||
type LLMFixture = {
|
type LLMFixture = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -51,6 +52,23 @@ type LLMFixture = {
|
||||||
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LLMWorker = LLMFixture & {
|
||||||
|
reset: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssistantFixture = {
|
||||||
|
reply: LLMFixture["text"]
|
||||||
|
tool: LLMFixture["tool"]
|
||||||
|
toolHang: LLMFixture["toolHang"]
|
||||||
|
reason: LLMFixture["reason"]
|
||||||
|
fail: LLMFixture["fail"]
|
||||||
|
error: LLMFixture["error"]
|
||||||
|
hang: LLMFixture["hang"]
|
||||||
|
hold: LLMFixture["hold"]
|
||||||
|
calls: LLMFixture["calls"]
|
||||||
|
pending: LLMFixture["pending"]
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
const seedModel = (() => {
|
const seedModel = (() => {
|
||||||
|
|
@ -63,6 +81,40 @@ const seedModel = (() => {
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
function clean(value: string | null) {
|
||||||
|
return (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visit(page: Page, url: string) {
|
||||||
|
let err: unknown
|
||||||
|
for (const _ of [0, 1, 2]) {
|
||||||
|
try {
|
||||||
|
await page.goto(url)
|
||||||
|
return
|
||||||
|
} catch (cause) {
|
||||||
|
err = cause
|
||||||
|
if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSend(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const sent = win.__opencode_e2e?.prompt?.sent
|
||||||
|
return {
|
||||||
|
started: sent?.started ?? 0,
|
||||||
|
count: sent?.count ?? 0,
|
||||||
|
sessionID: sent?.sessionID,
|
||||||
|
directory: sent?.directory,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
|
||||||
|
}
|
||||||
|
|
||||||
type ProjectHandle = {
|
type ProjectHandle = {
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
|
|
@ -79,16 +131,23 @@ type ProjectOptions = {
|
||||||
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectFixture = ProjectHandle & {
|
||||||
|
open: (options?: ProjectOptions) => Promise<void>
|
||||||
|
prompt: (text: string) => Promise<string>
|
||||||
|
user: (text: string) => Promise<string>
|
||||||
|
shell: (cmd: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
llm: LLMFixture
|
llm: LLMFixture
|
||||||
|
assistant: AssistantFixture
|
||||||
|
project: ProjectFixture
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
|
_llm: LLMWorker
|
||||||
backend: {
|
backend: {
|
||||||
url: string
|
url: string
|
||||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||||
|
|
@ -98,9 +157,42 @@ type WorkerFixtures = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
|
_llm: [
|
||||||
|
async ({}, use) => {
|
||||||
|
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||||
|
try {
|
||||||
|
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||||
|
await use({
|
||||||
|
url: svc.url,
|
||||||
|
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||||
|
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
||||||
|
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
||||||
|
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
||||||
|
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
||||||
|
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||||
|
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||||
|
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
||||||
|
fail: (message) => rt.runPromise(svc.fail(message)),
|
||||||
|
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||||
|
hang: () => rt.runPromise(svc.hang),
|
||||||
|
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||||
|
reset: () => rt.runPromise(svc.reset),
|
||||||
|
hits: () => rt.runPromise(svc.hits),
|
||||||
|
calls: () => rt.runPromise(svc.calls),
|
||||||
|
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||||
|
inputs: () => rt.runPromise(svc.inputs),
|
||||||
|
pending: () => rt.runPromise(svc.pending),
|
||||||
|
misses: () => rt.runPromise(svc.misses),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
backend: [
|
backend: [
|
||||||
async ({}, use, workerInfo) => {
|
async ({ _llm }, use, workerInfo) => {
|
||||||
const handle = await startBackend(`w${workerInfo.workerIndex}`)
|
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
|
||||||
try {
|
try {
|
||||||
await use({
|
await use({
|
||||||
url: handle.url,
|
url: handle.url,
|
||||||
|
|
@ -112,35 +204,48 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
llm: async ({}, use) => {
|
llm: async ({ _llm }, use) => {
|
||||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
await _llm.reset()
|
||||||
try {
|
await use({
|
||||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
url: _llm.url,
|
||||||
await use({
|
push: _llm.push,
|
||||||
url: svc.url,
|
pushMatch: _llm.pushMatch,
|
||||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
textMatch: _llm.textMatch,
|
||||||
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
toolMatch: _llm.toolMatch,
|
||||||
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
text: _llm.text,
|
||||||
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
tool: _llm.tool,
|
||||||
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
toolHang: _llm.toolHang,
|
||||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
reason: _llm.reason,
|
||||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
fail: _llm.fail,
|
||||||
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
error: _llm.error,
|
||||||
fail: (message) => rt.runPromise(svc.fail(message)),
|
hang: _llm.hang,
|
||||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
hold: _llm.hold,
|
||||||
hang: () => rt.runPromise(svc.hang),
|
hits: _llm.hits,
|
||||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
calls: _llm.calls,
|
||||||
hits: () => rt.runPromise(svc.hits),
|
wait: _llm.wait,
|
||||||
calls: () => rt.runPromise(svc.calls),
|
inputs: _llm.inputs,
|
||||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
pending: _llm.pending,
|
||||||
inputs: () => rt.runPromise(svc.inputs),
|
misses: _llm.misses,
|
||||||
pending: () => rt.runPromise(svc.pending),
|
})
|
||||||
misses: () => rt.runPromise(svc.misses),
|
const pending = await _llm.pending()
|
||||||
})
|
if (pending > 0) {
|
||||||
} finally {
|
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
|
||||||
await rt.dispose()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
assistant: async ({ llm }, use) => {
|
||||||
|
await use({
|
||||||
|
reply: llm.text,
|
||||||
|
tool: llm.tool,
|
||||||
|
toolHang: llm.toolHang,
|
||||||
|
reason: llm.reason,
|
||||||
|
fail: llm.fail,
|
||||||
|
error: llm.error,
|
||||||
|
hang: llm.hang,
|
||||||
|
hold: llm.hold,
|
||||||
|
calls: llm.calls,
|
||||||
|
pending: llm.pending,
|
||||||
|
})
|
||||||
|
},
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
let boundary: string | undefined
|
let boundary: string | undefined
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
|
|
@ -165,9 +270,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
if (boundary) throw new Error(boundary)
|
if (boundary) throw new Error(boundary)
|
||||||
},
|
},
|
||||||
directory: [
|
directory: [
|
||||||
async ({}, use) => {
|
async ({ backend }, use) => {
|
||||||
const directory = await getWorktree()
|
await use(await getWorktree(backend.url))
|
||||||
await use(directory)
|
|
||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
|
|
@ -177,93 +281,254 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
sdk: async ({ directory }, use) => {
|
sdk: async ({ directory, backend }, use) => {
|
||||||
await use(createSdk(directory))
|
await use(backend.sdk(directory))
|
||||||
},
|
},
|
||||||
gotoSession: async ({ page, directory }, use) => {
|
gotoSession: async ({ page, directory, backend }, use) => {
|
||||||
await seedStorage(page, { directory })
|
await seedStorage(page, { directory, serverUrl: backend.url })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await visit(page, sessionPath(directory, sessionID))
|
||||||
await waitSession(page, { directory, sessionID })
|
await waitSession(page, {
|
||||||
|
directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
project: async ({ page, llm, backend }, use) => {
|
||||||
await use((callback, options) => runProject(page, callback, options))
|
const item = makeProject(page, llm, backend)
|
||||||
},
|
try {
|
||||||
withBackendProject: async ({ page, backend }, use) => {
|
await use(item.project)
|
||||||
await use((callback, options) =>
|
} finally {
|
||||||
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
|
await item.cleanup()
|
||||||
)
|
}
|
||||||
},
|
|
||||||
withMockProject: async ({ page, llm, backend }, use) => {
|
|
||||||
await use((callback, options) =>
|
|
||||||
withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: () =>
|
|
||||||
runProject(page, callback, {
|
|
||||||
...options,
|
|
||||||
model: options?.model ?? openaiModel,
|
|
||||||
serverUrl: backend.url,
|
|
||||||
sdk: backend.sdk,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function runProject<T>(
|
function makeProject(
|
||||||
page: Page,
|
page: Page,
|
||||||
callback: (project: ProjectHandle) => Promise<T>,
|
llm: LLMFixture,
|
||||||
options?: ProjectOptions & {
|
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
|
||||||
serverUrl?: string
|
|
||||||
sdk?: (directory?: string) => ReturnType<typeof createSdk>
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const url = options?.serverUrl
|
let state:
|
||||||
const root = await createTestProject(url ? { serverUrl: url } : undefined)
|
| {
|
||||||
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
|
directory: string
|
||||||
const sessions = new Map<string, string>()
|
slug: string
|
||||||
const dirs = new Set<string>()
|
sdk: ReturnType<typeof createSdk>
|
||||||
await options?.setup?.(root)
|
sessions: Map<string, string>
|
||||||
await seedStorage(page, {
|
dirs: Set<string>
|
||||||
directory: root,
|
}
|
||||||
extra: options?.extra,
|
| undefined
|
||||||
model: options?.model,
|
|
||||||
serverUrl: url,
|
const need = () => {
|
||||||
})
|
if (state) return state
|
||||||
|
throw new Error("project.open() must be called first")
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
const cur = need()
|
||||||
|
cur.sessions.set(sessionID, directory ?? cur.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
const cur = need()
|
||||||
|
if (directory !== cur.directory) cur.dirs.add(directory)
|
||||||
|
}
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(root, sessionID))
|
const cur = need()
|
||||||
await waitSession(page, { directory: root, sessionID, serverUrl: url })
|
await visit(page, sessionPath(cur.directory, sessionID))
|
||||||
|
await waitSession(page, {
|
||||||
|
directory: cur.directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (current) trackSession(current)
|
if (current) trackSession(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackSession = (sessionID: string, directory?: string) => {
|
const open = async (options?: ProjectOptions) => {
|
||||||
sessions.set(sessionID, directory ?? root)
|
if (state) return
|
||||||
}
|
const directory = await createTestProject({ serverUrl: backend.url })
|
||||||
|
const sdk = backend.sdk(directory)
|
||||||
const trackDirectory = (directory: string) => {
|
await options?.setup?.(directory)
|
||||||
if (directory !== root) dirs.add(directory)
|
await seedStorage(page, {
|
||||||
}
|
directory,
|
||||||
|
extra: options?.extra,
|
||||||
try {
|
model: options?.model,
|
||||||
await options?.beforeGoto?.({ directory: root, sdk })
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
state = {
|
||||||
|
directory,
|
||||||
|
slug: "",
|
||||||
|
sdk,
|
||||||
|
sessions: new Map(),
|
||||||
|
dirs: new Set(),
|
||||||
|
}
|
||||||
|
await options?.beforeGoto?.({ directory, sdk })
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
const slug = await waitSlug(page)
|
need().slug = await waitSlug(page)
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
|
}
|
||||||
} finally {
|
|
||||||
|
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
|
||||||
|
if (input.noReply) {
|
||||||
|
const cur = need()
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const model = (window as E2EWindow).__opencode_e2e?.model?.current
|
||||||
|
if (!model) return null
|
||||||
|
return {
|
||||||
|
dir: model.dir,
|
||||||
|
sessionID: model.sessionID,
|
||||||
|
agent: model.agent,
|
||||||
|
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
|
||||||
|
variant: model.variant ?? undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const dir = state?.dir ?? cur.directory
|
||||||
|
const sdk = backend.sdk(dir)
|
||||||
|
const sessionID = state?.sessionID
|
||||||
|
? state.sessionID
|
||||||
|
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
|
||||||
|
if (!res.data?.id) throw new Error("Failed to create no-reply session")
|
||||||
|
return res.data.id
|
||||||
|
})
|
||||||
|
await sdk.session.prompt({
|
||||||
|
sessionID,
|
||||||
|
agent: state?.agent,
|
||||||
|
model: state?.model,
|
||||||
|
variant: state?.variant,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
})
|
||||||
|
await visit(page, sessionPath(dir, sessionID))
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: dir,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(sessionID, active.directory)
|
||||||
|
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = await promptSend(page)
|
||||||
|
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
|
||||||
|
await llm.text("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector).first()
|
||||||
|
const submit = async () => {
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.click()
|
||||||
|
if (input.shell) {
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
}
|
||||||
|
await page.keyboard.type(text)
|
||||||
|
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
const started = await expect
|
||||||
|
.poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
|
||||||
|
.toBeGreaterThan(prev.started)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (started) return
|
||||||
|
const send = page.getByRole("button", { name: "Send" }).first()
|
||||||
|
const enabled = await send
|
||||||
|
.isEnabled()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (enabled) {
|
||||||
|
await send.click()
|
||||||
|
} else {
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
|
||||||
|
}
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
let next: { sessionID: string; directory: string } | undefined
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const sent = await promptSend(page)
|
||||||
|
if (sent.count <= prev.count) return ""
|
||||||
|
if (!sent.sessionID || !sent.directory) return ""
|
||||||
|
next = { sessionID: sent.sessionID, directory: sent.directory }
|
||||||
|
return sent.sessionID
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: next.directory,
|
||||||
|
sessionID: next.sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(next.sessionID, active.directory)
|
||||||
|
if (!input.shell) {
|
||||||
|
await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
|
||||||
|
}
|
||||||
|
await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
|
||||||
|
return next.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = async (text: string) => {
|
||||||
|
return send(text, { noReply: false, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = async (text: string) => {
|
||||||
|
return send(text, { noReply: true, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const shell = async (cmd: string) => {
|
||||||
|
return send(cmd, { noReply: false, shell: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
const cur = state
|
||||||
|
if (!cur) return
|
||||||
setHealthPhase(page, "cleanup")
|
setHealthPhase(page, "cleanup")
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
|
Array.from(cur.sessions, ([sessionID, directory]) =>
|
||||||
|
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
|
||||||
await cleanupTestProject(root)
|
await cleanupTestProject(cur.directory)
|
||||||
|
state = undefined
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
open,
|
||||||
|
prompt,
|
||||||
|
user,
|
||||||
|
shell,
|
||||||
|
gotoSession,
|
||||||
|
trackSession,
|
||||||
|
trackDirectory,
|
||||||
|
get directory() {
|
||||||
|
return need().directory
|
||||||
|
},
|
||||||
|
get slug() {
|
||||||
|
return need().slug
|
||||||
|
},
|
||||||
|
get sdk() {
|
||||||
|
return need().sdk
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedStorage(
|
async function seedStorage(
|
||||||
|
|
@ -275,31 +540,65 @@ async function seedStorage(
|
||||||
serverUrl?: string
|
serverUrl?: string
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await seedProjects(page, input)
|
const origin = input.serverUrl ?? serverUrl
|
||||||
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
await page.addInitScript(
|
||||||
const win = window as E2EWindow
|
(args: {
|
||||||
win.__opencode_e2e = {
|
directory: string
|
||||||
...win.__opencode_e2e,
|
serverUrl: string
|
||||||
model: {
|
extra: string[]
|
||||||
enabled: true,
|
model: { providerID: string; modelID: string }
|
||||||
},
|
}) => {
|
||||||
prompt: {
|
const key = "opencode.global.dat:server"
|
||||||
enabled: true,
|
const raw = localStorage.getItem(key)
|
||||||
},
|
const parsed = (() => {
|
||||||
terminal: {
|
if (!raw) return undefined
|
||||||
enabled: true,
|
try {
|
||||||
terminals: {},
|
return JSON.parse(raw) as unknown
|
||||||
},
|
} catch {
|
||||||
}
|
return undefined
|
||||||
localStorage.setItem(
|
}
|
||||||
"opencode.global.dat:model",
|
})()
|
||||||
JSON.stringify({
|
|
||||||
recent: [model],
|
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||||
user: [],
|
const list = Array.isArray(store.list) ? store.list : []
|
||||||
variant: {},
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
}),
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
)
|
const next = { ...(projects as Record<string, unknown>) }
|
||||||
}, input.model ?? seedModel)
|
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||||
|
|
||||||
|
const add = (origin: string, directory: string) => {
|
||||||
|
const current = next[origin]
|
||||||
|
const items = Array.isArray(current) ? current : []
|
||||||
|
const existing = items.filter(
|
||||||
|
(p): p is { worktree: string; expanded?: boolean } =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === "object" &&
|
||||||
|
"worktree" in p &&
|
||||||
|
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||||
|
)
|
||||||
|
if (existing.some((p) => p.worktree === directory)) return
|
||||||
|
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of [args.directory, ...args.extra]) {
|
||||||
|
add("local", directory)
|
||||||
|
add(args.serverUrl, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
|
||||||
|
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
|
||||||
|
|
||||||
|
const win = window as E2EWindow
|
||||||
|
win.__opencode_e2e = {
|
||||||
|
...win.__opencode_e2e,
|
||||||
|
model: { enabled: true },
|
||||||
|
prompt: { enabled: true },
|
||||||
|
terminal: { enabled: true, terminals: {} },
|
||||||
|
}
|
||||||
|
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
|
||||||
|
},
|
||||||
|
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { expect }
|
export { expect }
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,49 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
test("dialog edit project updates name and startup script", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
await project.open()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const menu = await openProjectMenu(page, slug)
|
const menu = await openProjectMenu(page, project.slug)
|
||||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = `e2e project ${Date.now()}`
|
const name = `e2e project ${Date.now()}`
|
||||||
const startup = `echo e2e_${Date.now()}`
|
const startup = `echo e2e_${Date.now()}`
|
||||||
|
|
||||||
const dialog = await open()
|
const dialog = await open()
|
||||||
|
|
||||||
const nameInput = dialog.getByLabel("Name")
|
const nameInput = dialog.getByLabel("Name")
|
||||||
await nameInput.fill(name)
|
await nameInput.fill(name)
|
||||||
|
|
||||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||||
await startupInput.fill(startup)
|
await startupInput.fill(startup)
|
||||||
|
|
||||||
await dialog.getByRole("button", { name: "Save" }).click()
|
await dialog.getByRole("button", { name: "Save" }).click()
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
const header = page.locator(".group\\/project").first()
|
await expect
|
||||||
await expect(header).toContainText(name)
|
.poll(
|
||||||
|
async () => {
|
||||||
const reopened = await open()
|
await page.reload()
|
||||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
await openSidebar(page)
|
||||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
const reopened = await open()
|
||||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
const value = await reopened.getByLabel("Name").inputValue()
|
||||||
await expect(reopened).toHaveCount(0)
|
const next = await reopened.getByLabel("Workspace startup script").inputValue()
|
||||||
})
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
|
await expect(reopened).toHaveCount(0)
|
||||||
|
return `${value}\n${next}`
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${name}\n${startup}`)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,51 +3,46 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
|
||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
test("closing active project navigates to another open project", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ slug }) => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, otherSlug)
|
const menu = await openProjectMenu(page, otherSlug)
|
||||||
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const pathname = new URL(page.url()).pathname
|
||||||
|
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
|
if (pathname === "/") return "home"
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toMatch(/^(project|home)$/)
|
||||||
|
|
||||||
await expect
|
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||||
.poll(
|
await expect
|
||||||
() => {
|
.poll(
|
||||||
const pathname = new URL(page.url()).pathname
|
async () => {
|
||||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||||
if (pathname === "/") return "home"
|
},
|
||||||
return ""
|
{ timeout: 15_000 },
|
||||||
},
|
)
|
||||||
{ timeout: 15_000 },
|
.toBe(0)
|
||||||
)
|
|
||||||
.toMatch(/^(project|home)$/)
|
|
||||||
|
|
||||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
|
||||||
},
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,114 +5,89 @@ import {
|
||||||
createTestProject,
|
createTestProject,
|
||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
waitSession,
|
waitSession,
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
waitSlug,
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ directory }) => {
|
await defocus(page)
|
||||||
await defocus(page)
|
|
||||||
|
|
||||||
const currentSlug = dirSlug(directory)
|
const currentSlug = dirSlug(project.directory)
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||||
await expect(currentButton).toBeVisible()
|
await expect(currentButton).toBeVisible()
|
||||||
await currentButton.click()
|
await currentButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
await defocus(page)
|
||||||
await defocus(page)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const raw = await waitSlug(page, [slug])
|
const raw = await waitSlug(page, [project.slug])
|
||||||
const dir = base64Decode(raw)
|
const dir = base64Decode(raw)
|
||||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||||
const space = await resolveDirectory(dir)
|
const space = await resolveDirectory(dir)
|
||||||
const next = dirSlug(space)
|
const next = dirSlug(space)
|
||||||
trackDirectory(space)
|
project.trackDirectory(space)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||||
await expect(item).toBeVisible()
|
await expect(item).toBeVisible()
|
||||||
await item.hover()
|
await item.hover()
|
||||||
|
|
||||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||||
await expect(btn).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
await waitSession(page, { directory: space })
|
await waitSession(page, { directory: space })
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
const created = await project.user("test")
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.fill("test")
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for the URL to update with the new session ID
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const created = sessionIDFromUrl(page.url())
|
await openSidebar(page)
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
|
||||||
trackSession(created, space)
|
|
||||||
await waitSessionSaved(space, created)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click({ force: true })
|
||||||
|
await waitSession(page, { directory: other })
|
||||||
|
|
||||||
await openSidebar(page)
|
const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
|
||||||
|
await expect(rootButton).toBeVisible()
|
||||||
|
await rootButton.click({ force: true })
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
await waitSession(page, { directory: space, sessionID: created })
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
await otherButton.click({ force: true })
|
|
||||||
await waitSession(page, { directory: other })
|
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
await expect(rootButton).toBeVisible()
|
|
||||||
await rootButton.click({ force: true })
|
|
||||||
|
|
||||||
await waitSession(page, { directory: space, sessionID: created })
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,9 @@ import {
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
waitDir,
|
waitDir,
|
||||||
waitSession,
|
waitSession,
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
waitSlug,
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
function item(space: { slug: string; raw: string }) {
|
function item(space: { slug: string; raw: string }) {
|
||||||
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
|
|
@ -51,47 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(
|
async function createSessionFromWorkspace(
|
||||||
|
project: Parameters<typeof test>[0]["project"],
|
||||||
page: Page,
|
page: Page,
|
||||||
space: { slug: string; raw: string; directory: string },
|
space: { slug: string; raw: string; directory: string },
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
await openWorkspaceNewSession(page, space)
|
await openWorkspaceNewSession(page, space)
|
||||||
|
return project.user(text)
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.fill(text)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
|
|
||||||
await waitSessionSaved(space.directory, sessionID)
|
|
||||||
await createSdk(space.directory)
|
|
||||||
.session.abort({ sessionID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
return sessionID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
await project.open()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const first = await createWorkspace(page, project.slug, [])
|
||||||
trackDirectory(first.directory)
|
project.trackDirectory(first.directory)
|
||||||
await waitWorkspaceReady(page, first)
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const second = await createWorkspace(page, project.slug, [first.slug])
|
||||||
trackDirectory(second.directory)
|
project.trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
|
||||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ import {
|
||||||
waitDir,
|
waitDir,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
|
||||||
const rootSlug = project.slug
|
const rootSlug = project.slug
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
|
|
@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
await waitDir(page, next.directory)
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
|
|
@ -52,44 +53,192 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
return { rootSlug, slug: next.slug, directory: next.directory }
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
test("can enable and disable workspaces from project menu", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, false)
|
await setWorkspacesEnabled(page, project.slug, false)
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can create a workspace", async ({ page, withProject }) => {
|
test("can create a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [project.slug]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||||
await openSidebar(page)
|
const nonGitSlug = dirSlug(nonGit)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
try {
|
||||||
const next = await resolveSlug(await waitSlug(page, [slug]))
|
await project.open({ extra: [nonGit] })
|
||||||
await waitDir(page, next.directory)
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
|
|
||||||
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||||
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(nonGit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can rename a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const { slug } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const rename = `e2e workspace ${Date.now()}`
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||||
|
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
const input = item.locator(inlineInputSelector).first()
|
||||||
|
const shown = await input
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!shown) {
|
||||||
|
const retry = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(retry, /^Rename$/i, { force: true })
|
||||||
|
await expect(retry).toHaveCount(0)
|
||||||
|
}
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(rename)
|
||||||
|
await input.press("Enter")
|
||||||
|
await expect(item).toContainText(rename)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reset a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const readme = path.join(createdDir, "README.md")
|
||||||
|
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||||
|
const original = await fs.readFile(readme, "utf8")
|
||||||
|
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
||||||
|
await fs.writeFile(readme, dirty, "utf8")
|
||||||
|
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const files = await project.sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Reset workspace$/i)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const files = await project.sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
|
||||||
|
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reorder workspaces by drag and drop", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
const rootSlug = project.slug
|
||||||
|
|
||||||
|
const listSlugs = async () => {
|
||||||
|
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||||
|
const slugs = await nodes.evaluateAll((els) => {
|
||||||
|
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||||
|
})
|
||||||
|
return slugs
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitReady = async (slug: string) => {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
|
|
@ -100,276 +249,120 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
|
||||||
|
|
||||||
await cleanupTestProject(next.directory)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
|
||||||
const nonGitSlug = dirSlug(nonGit)
|
|
||||||
|
|
||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(async () => {
|
|
||||||
await page.goto(`/${nonGitSlug}/session`)
|
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
|
||||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
|
|
||||||
const trigger = page.locator('[data-action="project-menu"]').first()
|
|
||||||
const hasMenu = await trigger
|
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!hasMenu) return
|
|
||||||
|
|
||||||
await trigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
await expect(toggle).toBeDisabled()
|
|
||||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(nonGit)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
test("can rename a workspace", async ({ page, withProject }) => {
|
const drag = async (from: string, to: string) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
|
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||||
|
|
||||||
await withProject(async (project) => {
|
const a = await src.boundingBox()
|
||||||
const { slug } = await setupWorkspaceTest(page, project)
|
const b = await dst.boundingBox()
|
||||||
|
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||||
|
|
||||||
const rename = `e2e workspace ${Date.now()}`
|
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
await page.mouse.down()
|
||||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
|
||||||
await expect(menu).toHaveCount(0)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
await expect(item).toBeVisible()
|
|
||||||
const input = item.locator(inlineInputSelector).first()
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await input.fill(rename)
|
|
||||||
await input.press("Enter")
|
|
||||||
await expect(item).toContainText(rename)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
const workspaces = [] as { directory: string; slug: string }[]
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
for (const _ of [0, 1]) {
|
||||||
|
const prev = slugFromUrl(page.url())
|
||||||
await withProject(async (project) => {
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
const readme = path.join(createdDir, "README.md")
|
project.trackDirectory(next.directory)
|
||||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
workspaces.push(next)
|
||||||
const original = await fs.readFile(readme, "utf8")
|
|
||||||
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
|
||||||
await fs.writeFile(readme, dirty, "utf8")
|
|
||||||
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await fs
|
|
||||||
.stat(extra)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const files = await sdk.file
|
|
||||||
.status({ directory: createdDir })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return files.length
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Reset workspace$/i)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const files = await sdk.file
|
|
||||||
.status({ directory: createdDir })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return files.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
|
|
||||||
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await fs
|
|
||||||
.stat(extra)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
})
|
|
||||||
.toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can delete a workspace", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const sdk = createSdk(project.directory)
|
|
||||||
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
|
||||||
|
|
||||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(false)
|
|
||||||
|
|
||||||
await project.gotoSession()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
}
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
|
||||||
})
|
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||||
|
|
||||||
|
const a = workspaces[0].slug
|
||||||
|
const b = workspaces[1].slug
|
||||||
|
|
||||||
|
await waitReady(a)
|
||||||
|
await waitReady(b)
|
||||||
|
|
||||||
|
const list = async () => {
|
||||||
|
const slugs = await listSlugs()
|
||||||
|
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const slugs = await list()
|
||||||
|
return slugs.length === 2
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const before = await list()
|
||||||
|
const from = before[1]
|
||||||
|
const to = before[0]
|
||||||
|
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||||
|
|
||||||
|
await drag(from, to)
|
||||||
|
|
||||||
|
await expect.poll(async () => await list()).toEqual([from, to])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
test("can delete a workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
await withProject(async ({ slug: rootSlug }) => {
|
await project.open()
|
||||||
const workspaces = [] as { directory: string; slug: string }[]
|
|
||||||
|
|
||||||
const listSlugs = async () => {
|
const rootSlug = project.slug
|
||||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
await openSidebar(page)
|
||||||
const slugs = await nodes.evaluateAll((els) => {
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
|
||||||
})
|
|
||||||
return slugs
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitReady = async (slug: string) => {
|
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
|
||||||
await expect
|
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const drag = async (from: string, to: string) => {
|
const directory = created.directory
|
||||||
const src = page.locator(workspaceItemSelector(from)).first()
|
const slug = dirSlug(directory)
|
||||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
project.trackDirectory(directory)
|
||||||
|
|
||||||
const a = await src.boundingBox()
|
await page.reload()
|
||||||
const b = await dst.boundingBox()
|
await openSidebar(page)
|
||||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
|
|
||||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
await expect
|
||||||
await page.mouse.down()
|
.poll(
|
||||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
async () => {
|
||||||
await page.mouse.up()
|
const worktrees = await project.sdk.worktree
|
||||||
}
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
try {
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await openSidebar(page)
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
for (const _ of [0, 1]) {
|
await expect
|
||||||
const prev = slugFromUrl(page.url())
|
.poll(
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
async () => {
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
const worktrees = await project.sdk.worktree
|
||||||
await waitDir(page, next.directory)
|
.list()
|
||||||
workspaces.push(next)
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
}
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
|
||||||
|
|
||||||
const a = workspaces[0].slug
|
|
||||||
const b = workspaces[1].slug
|
|
||||||
|
|
||||||
await waitReady(a)
|
|
||||||
await waitReady(b)
|
|
||||||
|
|
||||||
const list = async () => {
|
|
||||||
const slugs = await listSlugs()
|
|
||||||
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const slugs = await list()
|
|
||||||
return slugs.length === 2
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const before = await list()
|
|
||||||
const from = before[1]
|
|
||||||
const to = before[0]
|
|
||||||
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
|
||||||
|
|
||||||
await drag(from, to)
|
|
||||||
|
|
||||||
await expect.poll(async () => await list()).toEqual([from, to])
|
|
||||||
} finally {
|
|
||||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,9 @@
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
|
|
||||||
|
|
||||||
type Hit = { body: Record<string, unknown> }
|
type Hit = { body: Record<string, unknown> }
|
||||||
|
|
||||||
export function bodyText(hit: Hit) {
|
export function bodyText(hit: Hit) {
|
||||||
return JSON.stringify(hit.body)
|
return JSON.stringify(hit.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function titleMatch(hit: Hit) {
|
|
||||||
return bodyText(hit).includes("Generate a title for this conversation")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promptMatch(token: string) {
|
|
||||||
return (hit: Hit) => bodyText(hit).includes(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match requests whose body contains the exact serialized tool input.
|
* Match requests whose body contains the exact serialized tool input.
|
||||||
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
||||||
|
|
@ -25,32 +13,3 @@ export function inputMatch(input: unknown) {
|
||||||
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
||||||
return (hit: Hit) => bodyText(hit).includes(escaped)
|
return (hit: Hit) => bodyText(hit).includes(escaped)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
|
|
||||||
const sdk = createSdk(undefined, input.serverUrl)
|
|
||||||
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sdk.global.config.update({
|
|
||||||
config: {
|
|
||||||
...prev,
|
|
||||||
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
|
|
||||||
enabled_providers: ["openai"],
|
|
||||||
provider: {
|
|
||||||
...prev.provider,
|
|
||||||
openai: {
|
|
||||||
...prev.provider?.openai,
|
|
||||||
options: {
|
|
||||||
...prev.provider?.openai?.options,
|
|
||||||
apiKey: "test-key",
|
|
||||||
baseURL: input.llmUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return await input.fn()
|
|
||||||
} finally {
|
|
||||||
await sdk.global.config.update({ config: prev })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,25 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
|
import { assistantText, withSession } from "../actions"
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
test("prompt succeeds when sync message endpoint is unreachable", async ({
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
backend,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
await withMockOpenAI({
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
serverUrl: backend.url,
|
await project.open()
|
||||||
llmUrl: llm.url,
|
await assistant.reply(token)
|
||||||
fn: async () => {
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
const token = `E2E_ASYNC_${Date.now()}`
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
|
||||||
await llm.textMatch(promptMatch(token), token)
|
|
||||||
|
|
||||||
await withBackendProject(
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
async (project) => {
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
|
|
||||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
|
||||||
|
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
import { assistantText } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
type Sdk = ReturnType<typeof createSdk>
|
type Sdk = ReturnType<typeof createSdk>
|
||||||
|
|
@ -43,73 +42,45 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("prompt history restores unsent draft with arrow navigation", async ({
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
backend,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withMockOpenAI({
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
serverUrl: backend.url,
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
llmUrl: llm.url,
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
fn: async () => {
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
const draft = `draft ${Date.now()}`
|
||||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
|
||||||
const first = `Reply with exactly: ${firstToken}`
|
|
||||||
const second = `Reply with exactly: ${secondToken}`
|
|
||||||
const draft = `draft ${Date.now()}`
|
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
await project.open()
|
||||||
await llm.textMatch(promptMatch(firstToken), firstToken)
|
await assistant.reply(firstToken)
|
||||||
await llm.textMatch(promptMatch(secondToken), secondToken)
|
const sessionID = await project.prompt(first)
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(project.sdk, sessionID, firstToken)
|
||||||
|
|
||||||
await withBackendProject(
|
await assistant.reply(secondToken)
|
||||||
async (project) => {
|
await project.prompt(second)
|
||||||
const prompt = page.locator(promptSelector)
|
await wait(page, "")
|
||||||
|
await reply(project.sdk, sessionID, secondToken)
|
||||||
|
|
||||||
await prompt.click()
|
const prompt = page.locator(promptSelector)
|
||||||
await page.keyboard.type(first)
|
await prompt.click()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.type(draft)
|
||||||
await wait(page, "")
|
await wait(page, draft)
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await prompt.fill("")
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
await wait(page, "")
|
||||||
project.trackSession(sessionID)
|
|
||||||
await reply(project.sdk, sessionID, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowUp")
|
||||||
await page.keyboard.type(second)
|
await wait(page, second)
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await reply(project.sdk, sessionID, secondToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowUp")
|
||||||
await page.keyboard.type(draft)
|
await wait(page, first)
|
||||||
await wait(page, draft)
|
|
||||||
|
|
||||||
await prompt.fill("")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, "")
|
await wait(page, second)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, second)
|
await wait(page, "")
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
|
||||||
await wait(page, first)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, "")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
|
|
@ -10,33 +9,35 @@ const isBash = (part: unknown): part is ToolPart => {
|
||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
|
async function setAutoAccept(page: Parameters<typeof test>[0]["page"], enabled: boolean) {
|
||||||
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
const pressed = (await button.getAttribute("aria-pressed")) === "true"
|
||||||
|
if (pressed === enabled) return
|
||||||
|
await button.click()
|
||||||
|
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shell mode runs a command in the project directory", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
|
await project.open()
|
||||||
const prompt = page.locator(promptSelector)
|
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||||
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
|
||||||
|
|
||||||
await gotoSession()
|
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
|
||||||
await prompt.click()
|
project.trackSession(session.id)
|
||||||
await page.keyboard.type("!")
|
await project.gotoSession(session.id)
|
||||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
await setAutoAccept(page, true)
|
||||||
|
await project.shell(cmd)
|
||||||
await page.keyboard.type(cmd)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
trackSession(id, directory)
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
const list = await project.sdk.session
|
||||||
|
.messages({ sessionID: session.id, limit: 50 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
const msg = list.findLast(
|
const msg = list.findLast(
|
||||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
|
||||||
)
|
)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
|
|
@ -49,12 +50,10 @@ test("shell mode runs a command in the project directory", async ({ page, withBa
|
||||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
if (!output.includes("README.md")) return
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
return { cwd: directory, output }
|
return { cwd: project.directory, output }
|
||||||
},
|
},
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000 },
|
||||||
)
|
)
|
||||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
|
||||||
|
|
||||||
await expect(prompt).toHaveText("")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -22,46 +22,45 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
|
test("/share and /unshare update session share state", async ({ page, project }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
|
|
||||||
await seed(project.sdk, session.id)
|
await seed(project.sdk, session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("/share")
|
await page.keyboard.type("/share")
|
||||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("/unshare")
|
await page.keyboard.type("/unshare")
|
||||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { assistantText } from "../actions"
|
||||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
|
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const pageErrors: string[] = []
|
const pageErrors: string[] = []
|
||||||
|
|
@ -13,41 +11,13 @@ test("can send a prompt and receive a reply", async ({ page, llm, backend, withB
|
||||||
page.on("pageerror", onPageError)
|
page.on("pageerror", onPageError)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withMockOpenAI({
|
const token = `E2E_OK_${Date.now()}`
|
||||||
serverUrl: backend.url,
|
await project.open()
|
||||||
llmUrl: llm.url,
|
await assistant.reply(token)
|
||||||
fn: async () => {
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
const token = `E2E_OK_${Date.now()}`
|
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
await llm.textMatch(promptMatch(token), token)
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||||
|
|
||||||
await withBackendProject(
|
|
||||||
async (project) => {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const sessionID = (() => {
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
return id
|
|
||||||
})()
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
|
|
||||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
|
||||||
|
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
|
||||||
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
|
||||||
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
|
||||||
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
|
||||||
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
|
||||||
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||||
|
|
@ -30,7 +24,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
|
||||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||||
|
|
||||||
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||||
|
|
||||||
export const projectSwitchSelector = (slug: string) =>
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
|
|
@ -40,9 +34,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectClearNotificationsSelector = (slug: string) =>
|
|
||||||
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|
@ -50,8 +41,6 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
|
||||||
|
|
||||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||||
|
|
||||||
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
|
||||||
|
|
||||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||||
|
|
||||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { test, expect } from "../fixtures"
|
||||||
import { inputMatch } from "../prompt/mock"
|
import { inputMatch } from "../prompt/mock"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => {
|
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const errs: string[] = []
|
const errs: string[] = []
|
||||||
|
|
@ -13,34 +13,33 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
||||||
page.on("pageerror", onError)
|
page.on("pageerror", onError)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withMockProject(async ({ gotoSession, trackSession, sdk }) => {
|
await project.open()
|
||||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||||
const taskInput = {
|
const taskInput = {
|
||||||
description: "Open child session",
|
description: "Open child session",
|
||||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||||
subagent_type: "general",
|
subagent_type: "general",
|
||||||
}
|
}
|
||||||
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
|
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
|
||||||
const child = await seedSessionTask(sdk, {
|
const child = await seedSessionTask(project.sdk, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
description: taskInput.description,
|
description: taskInput.description,
|
||||||
prompt: taskInput.prompt,
|
prompt: taskInput.prompt,
|
||||||
})
|
|
||||||
trackSession(child.sessionID)
|
|
||||||
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const link = page
|
|
||||||
.locator("a.subagent-link")
|
|
||||||
.filter({ hasText: /open child session/i })
|
|
||||||
.first()
|
|
||||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
|
||||||
await link.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
project.trackSession(child.sessionID)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
const link = page
|
||||||
|
.locator("a.subagent-link")
|
||||||
|
.filter({ hasText: /open child session/i })
|
||||||
|
.first()
|
||||||
|
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||||
|
await link.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
||||||
|
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onError)
|
page.off("pageerror", onError)
|
||||||
|
|
|
||||||
|
|
@ -242,9 +242,7 @@ async function withMockPermission<T>(
|
||||||
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
||||||
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: res.status(),
|
response: res,
|
||||||
headers: res.headers(),
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(json),
|
body: JSON.stringify(json),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -269,240 +267,227 @@ async function withMockPermission<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
|
test("default dock shows prompt input", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock default",
|
"e2e composer dock default",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await expect(page.locator(promptSelector)).toBeFocused()
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("auto-accept toggle works before first submit", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||||
|
|
||||||
|
await setAutoAccept(page, true)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock question",
|
||||||
|
async (session) => {
|
||||||
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await seedSessionQuestion(project.sdk, {
|
||||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
sessionID: session.id,
|
||||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
questions: defaultQuestions,
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await expect(page.locator(promptSelector)).toBeFocused()
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async ({ gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
|
||||||
|
|
||||||
await setAutoAccept(page, true)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock question",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: defaultQuestions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
|
||||||
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
const dock = page.locator(questionDockSelector)
|
||||||
)
|
await expectQuestionBlocked(page)
|
||||||
})
|
|
||||||
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
|
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock question keyboard",
|
"e2e composer dock question keyboard",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
await seedSessionQuestion(project.sdk, {
|
await seedSessionQuestion(project.sdk, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
questions: defaultQuestions,
|
questions: defaultQuestions,
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
const first = dock.locator('[data-slot="question-option"]').first()
|
|
||||||
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
await expect(first).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await expect(second).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Space")
|
|
||||||
await page.keyboard.press(`${modKey}+Enter`)
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
const dock = page.locator(questionDockSelector)
|
||||||
)
|
const first = dock.locator('[data-slot="question-option"]').first()
|
||||||
})
|
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await expect(second).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Space")
|
||||||
|
await page.keyboard.press(`${modKey}+Enter`)
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => {
|
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock question escape",
|
"e2e composer dock question escape",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
await seedSessionQuestion(project.sdk, {
|
await seedSessionQuestion(project.sdk, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
questions: defaultQuestions,
|
questions: defaultQuestions,
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
const first = dock.locator('[data-slot="question-option"]').first()
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
await expect(first).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
const dock = page.locator(questionDockSelector)
|
||||||
)
|
const first = dock.locator('[data-slot="question-option"]').first()
|
||||||
})
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
|
test("blocked permission flow supports allow once", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock permission once",
|
"e2e composer dock permission once",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
id: "per_e2e_once",
|
id: "per_e2e_once",
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||||
metadata: { description: "Need permission for command" },
|
metadata: { description: "Need permission for command" },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async (state) => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionBlocked(page)
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
await state.resolved()
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
|
test("blocked permission flow supports reject", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock permission reject",
|
"e2e composer dock permission reject",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
id: "per_e2e_reject",
|
id: "per_e2e_reject",
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async (state) => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionBlocked(page)
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
await clearPermissionDock(page, /deny/i)
|
await clearPermissionDock(page, /deny/i)
|
||||||
await state.resolved()
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
|
test("blocked permission flow supports allow always", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock permission always",
|
"e2e composer dock permission always",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
id: "per_e2e_always",
|
id: "per_e2e_always",
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||||
metadata: { description: "Need permission for command" },
|
metadata: { description: "Need permission for command" },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async (state) => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionBlocked(page)
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow always/i)
|
await clearPermissionDock(page, /allow always/i)
|
||||||
await state.resolved()
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
withMockProject,
|
|
||||||
}) => {
|
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
header: "Child input",
|
header: "Child input",
|
||||||
|
|
@ -513,137 +498,131 @@ test("child session question request blocks parent dock and unblocks after submi
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock child question parent",
|
"e2e composer dock child question parent",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const child = await project.sdk.session
|
const child = await project.sdk.session
|
||||||
.create({
|
.create({
|
||||||
title: "e2e composer dock child question",
|
title: "e2e composer dock child question",
|
||||||
parentID: session.id,
|
parentID: session.id,
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
|
project.trackSession(child.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withDockSeed(project.sdk, child.id, async () => {
|
||||||
|
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
||||||
|
await seedSessionQuestion(project.sdk, {
|
||||||
|
sessionID: child.id,
|
||||||
|
questions,
|
||||||
})
|
})
|
||||||
.then((r) => r.data)
|
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
|
||||||
project.trackSession(child.id)
|
|
||||||
|
|
||||||
try {
|
const dock = page.locator(questionDockSelector)
|
||||||
await withDockSeed(project.sdk, child.id, async () => {
|
await expectQuestionBlocked(page)
|
||||||
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: child.id,
|
|
||||||
questions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
await expectQuestionBlocked(page)
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
await expectQuestionOpen(page)
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
})
|
||||||
|
} finally {
|
||||||
await expectQuestionOpen(page)
|
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||||
})
|
}
|
||||||
} finally {
|
},
|
||||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
{ trackSession: project.trackSession },
|
||||||
}
|
)
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => {
|
||||||
page,
|
await project.open()
|
||||||
withBackendProject,
|
await withDockSession(
|
||||||
}) => {
|
project.sdk,
|
||||||
await withBackendProject(async (project) => {
|
"e2e composer dock child permission parent",
|
||||||
await withDockSession(
|
async (session) => {
|
||||||
project.sdk,
|
await project.gotoSession(session.id)
|
||||||
"e2e composer dock child permission parent",
|
await setAutoAccept(page, false)
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
|
|
||||||
const child = await project.sdk.session
|
const child = await project.sdk.session
|
||||||
.create({
|
.create({
|
||||||
title: "e2e composer dock child permission",
|
title: "e2e composer dock child permission",
|
||||||
parentID: session.id,
|
parentID: session.id,
|
||||||
})
|
})
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
project.trackSession(child.id)
|
project.trackSession(child.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
id: "per_e2e_child",
|
id: "per_e2e_child",
|
||||||
sessionID: child.id,
|
sessionID: child.id,
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||||
metadata: { description: "Need child permission" },
|
metadata: { description: "Need child permission" },
|
||||||
},
|
},
|
||||||
{ child },
|
{ child },
|
||||||
async (state) => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionBlocked(page)
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
await state.resolved()
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
|
|
||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
|
test("todo dock transitions and collapse behavior", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock todo",
|
"e2e composer dock todo",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
const dock = await todoDock(page, session.id)
|
const dock = await todoDock(page, session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dock.open([
|
await dock.open([
|
||||||
{ content: "first task", status: "pending", priority: "high" },
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
])
|
])
|
||||||
await dock.expectOpen(["pending", "in_progress"])
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
await dock.collapse()
|
await dock.collapse()
|
||||||
await dock.expectCollapsed(["pending", "in_progress"])
|
await dock.expectCollapsed(["pending", "in_progress"])
|
||||||
|
|
||||||
await dock.expand()
|
await dock.expand()
|
||||||
await dock.expectOpen(["pending", "in_progress"])
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
await dock.finish([
|
await dock.finish([
|
||||||
{ content: "first task", status: "completed", priority: "high" },
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
])
|
])
|
||||||
await dock.expectClosed()
|
await dock.expectClosed()
|
||||||
} finally {
|
} finally {
|
||||||
await dock.clear()
|
await dock.clear()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => {
|
test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => {
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
header: "Need input",
|
header: "Need input",
|
||||||
|
|
@ -651,28 +630,27 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo
|
||||||
options: [{ label: "Continue", description: "Continue now" }],
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock keyboard",
|
"e2e composer dock keyboard",
|
||||||
async (session) => {
|
async (session) => {
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
||||||
await seedSessionQuestion(project.sdk, {
|
await seedSessionQuestion(project.sdk, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
questions,
|
questions,
|
||||||
})
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
|
||||||
await page.keyboard.type("abc")
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
await expectQuestionBlocked(page)
|
||||||
)
|
|
||||||
})
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await page.keyboard.type("abc")
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
import type { Locator, Page } from "@playwright/test"
|
import type { Locator, Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
|
||||||
openSidebar,
|
|
||||||
resolveSlug,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
waitSession,
|
|
||||||
waitSessionIdle,
|
|
||||||
waitSlug,
|
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
|
||||||
import {
|
import {
|
||||||
promptAgentSelector,
|
promptAgentSelector,
|
||||||
promptModelSelector,
|
promptModelSelector,
|
||||||
|
|
@ -230,35 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) {
|
||||||
await waitSession(page, { directory, sessionID })
|
await waitSession(page, { directory, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(page: Page, value: string) {
|
async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
|
||||||
const prompt = page.locator('[data-component="prompt-input"]')
|
return project.prompt(value)
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.click()
|
|
||||||
await prompt.fill(value)
|
|
||||||
await prompt.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitUser(directory: string, sessionID: string) {
|
|
||||||
const sdk = createSdk(directory)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
|
|
||||||
return items.some((item) => item.info.role === "user")
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
await sdk.session.abort({ sessionID }).catch(() => undefined)
|
|
||||||
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
|
|
@ -301,108 +265,98 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
||||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
|
test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
const firstState = await chooseOtherModel(page)
|
||||||
const firstKey = await currentModel(page)
|
const firstKey = await currentModel(page)
|
||||||
const first = await submit(page, `session variant ${Date.now()}`)
|
const first = await submit(project, `session variant ${Date.now()}`)
|
||||||
trackSession(first)
|
|
||||||
await waitUser(directory, first)
|
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await waitSession(page, { directory, sessionID: first })
|
await waitSession(page, { directory: project.directory, sessionID: first })
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
const fresh = await read(page)
|
const fresh = await read(page)
|
||||||
expect(fresh.model).not.toBe(firstState.model)
|
expect(fresh.model).not.toBe(firstState.model)
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
const secondState = await chooseOtherModel(page, [firstKey])
|
||||||
const second = await submit(page, `session model ${Date.now()}`)
|
const second = await submit(project, `session model ${Date.now()}`)
|
||||||
trackSession(second)
|
|
||||||
await waitUser(directory, second)
|
|
||||||
|
|
||||||
await goto(page, directory, first)
|
await goto(page, project.directory, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await goto(page, directory, second)
|
await goto(page, project.directory, second)
|
||||||
await waitFooter(page, secondState)
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await waitFooter(page, fresh)
|
await page.reload()
|
||||||
})
|
await waitSession(page, { directory: project.directory })
|
||||||
|
await waitFooter(page, fresh)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session model restore across workspaces", async ({ page, withProject }) => {
|
test("session model restore across workspaces", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
const root = project.directory
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
const firstState = await chooseOtherModel(page)
|
||||||
const firstKey = await currentModel(page)
|
const firstKey = await currentModel(page)
|
||||||
const first = await submit(page, `root session ${Date.now()}`)
|
const first = await submit(project, `root session ${Date.now()}`)
|
||||||
trackSession(first, root)
|
|
||||||
await waitUser(root, first)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
const one = await createWorkspace(page, slug, [])
|
const one = await createWorkspace(page, project.slug, [])
|
||||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||||
trackDirectory(oneDir)
|
project.trackDirectory(oneDir)
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
const secondState = await chooseOtherModel(page, [firstKey])
|
||||||
const secondKey = await currentModel(page)
|
const secondKey = await currentModel(page)
|
||||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
const second = await submit(project, `workspace one ${Date.now()}`)
|
||||||
trackSession(second, oneDir)
|
|
||||||
await waitUser(oneDir, second)
|
|
||||||
|
|
||||||
const two = await createWorkspace(page, slug, [one.slug])
|
const two = await createWorkspace(page, project.slug, [one.slug])
|
||||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||||
trackDirectory(twoDir)
|
project.trackDirectory(twoDir)
|
||||||
|
|
||||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
const third = await submit(project, `workspace two ${Date.now()}`)
|
||||||
trackSession(third, twoDir)
|
|
||||||
await waitUser(twoDir, third)
|
|
||||||
|
|
||||||
await goto(page, root, first)
|
await goto(page, root, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await goto(page, oneDir, second)
|
await goto(page, oneDir, second)
|
||||||
await waitFooter(page, secondState)
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
await goto(page, twoDir, third)
|
await goto(page, twoDir, third)
|
||||||
await waitFooter(page, thirdState)
|
await waitFooter(page, thirdState)
|
||||||
|
|
||||||
await goto(page, root, first)
|
await goto(page, root, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
test("variant preserved when switching agent modes", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
await ensureVariant(page, directory)
|
await ensureVariant(page, project.directory)
|
||||||
const updated = await chooseDifferentVariant(page)
|
const updated = await chooseDifferentVariant(page)
|
||||||
|
|
||||||
const available = await agents(page)
|
const available = await agents(page)
|
||||||
const other = available.find((name) => name !== updated.agent)
|
const other = available.find((name) => name !== updated.agent)
|
||||||
test.skip(!other, "only one agent available")
|
test.skip(!other, "only one agent available")
|
||||||
if (!other) return
|
if (!other) return
|
||||||
|
|
||||||
await choose(page, promptAgentSelector, other)
|
await choose(page, promptAgentSelector, other)
|
||||||
await waitFooter(page, { agent: other, variant: updated.variant })
|
await waitFooter(page, { agent: other, variant: updated.variant })
|
||||||
|
|
||||||
await choose(page, promptAgentSelector, updated.agent)
|
await choose(page, promptAgentSelector, updated.agent)
|
||||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { waitSessionIdle, withSession } from "../actions"
|
import { waitSessionIdle, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { inputMatch } from "../prompt/mock"
|
import { bodyText } from "../prompt/mock"
|
||||||
|
|
||||||
const count = 14
|
const count = 14
|
||||||
|
|
||||||
|
|
@ -47,8 +47,12 @@ async function patchWithMock(
|
||||||
patchText: string,
|
patchText: string,
|
||||||
) {
|
) {
|
||||||
const callsBefore = await llm.calls()
|
const callsBefore = await llm.calls()
|
||||||
await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText })
|
await llm.toolMatch(
|
||||||
await sdk.session.promptAsync({
|
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
|
||||||
|
"apply_patch",
|
||||||
|
{ patchText },
|
||||||
|
)
|
||||||
|
await sdk.session.prompt({
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "build",
|
agent: "build",
|
||||||
system: [
|
system: [
|
||||||
|
|
@ -61,12 +65,16 @@ async function patchWithMock(
|
||||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wait for the agent loop to actually start before checking idle.
|
|
||||||
// promptAsync is fire-and-forget — without this, waitSessionIdle can
|
|
||||||
// return immediately because the session status is still undefined.
|
|
||||||
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
||||||
|
await expect
|
||||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
|
@ -245,7 +253,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => {
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-comment-${Date.now()}`
|
const tag = `review-comment-${Date.now()}`
|
||||||
|
|
@ -254,46 +262,45 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
return diff.length
|
return diff.length
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(1)
|
.toBe(1)
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
await show(page)
|
await show(page)
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
await expect(tab).toBeVisible()
|
await expect(tab).toBeVisible()
|
||||||
await tab.click()
|
await tab.click()
|
||||||
|
|
||||||
await expand(page)
|
await expand(page)
|
||||||
await waitMark(page, file, tag)
|
await waitMark(page, file, tag)
|
||||||
await comment(page, file, note)
|
await comment(page, file, note)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => {
|
test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-file-comment-${Date.now()}`
|
const tag = `review-file-comment-${Date.now()}`
|
||||||
|
|
@ -302,47 +309,46 @@ test("review file comments submit on click without clipping actions", async ({ p
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
return diff.length
|
return diff.length
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(1)
|
.toBe(1)
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
await show(page)
|
await show(page)
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
await expect(tab).toBeVisible()
|
await expect(tab).toBeVisible()
|
||||||
await tab.click()
|
await tab.click()
|
||||||
|
|
||||||
await expand(page)
|
await expand(page)
|
||||||
await waitMark(page, file, tag)
|
await waitMark(page, file, tag)
|
||||||
await openReviewFile(page, file)
|
await openReviewFile(page, file)
|
||||||
await fileComment(page, note)
|
await fileComment(page, note)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => {
|
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-${Date.now()}`
|
const tag = `review-${Date.now()}`
|
||||||
|
|
@ -352,84 +358,83 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||||
return info?.summary?.files ?? 0
|
return info?.summary?.files ?? 0
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(list.length)
|
.toBe(list.length)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
return diff.length
|
return diff.length
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(list.length)
|
.toBe(list.length)
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
await show(page)
|
await show(page)
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
await expect(tab).toBeVisible()
|
await expect(tab).toBeVisible()
|
||||||
await tab.click()
|
await tab.click()
|
||||||
|
|
||||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
await expect(view).toBeVisible()
|
await expect(view).toBeVisible()
|
||||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||||
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
|
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
|
||||||
|
|
||||||
await expand(page)
|
await expand(page)
|
||||||
await waitMark(page, hit.file, hit.mark)
|
await waitMark(page, hit.file, hit.mark)
|
||||||
|
|
||||||
const row = page
|
const row = page
|
||||||
.getByRole("heading", {
|
.getByRole("heading", {
|
||||||
level: 3,
|
level: 3,
|
||||||
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||||
})
|
})
|
||||||
.first()
|
.first()
|
||||||
await expect(row).toBeVisible()
|
await expect(row).toBeVisible()
|
||||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||||
|
|
||||||
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||||
const prev = await spot(page, hit.file)
|
const prev = await spot(page, hit.file)
|
||||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||||
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
|
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
const item = diff.find((item) => item.file === hit.file)
|
const item = diff.find((item) => item.file === hit.file)
|
||||||
return typeof item?.after === "string" ? item.after : ""
|
return typeof item?.after === "string" ? item.after : ""
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toContain(`mark ${next}`)
|
.toContain(`mark ${next}`)
|
||||||
|
|
||||||
await waitMark(page, hit.file, next)
|
await waitMark(page, hit.file, next)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const next = await spot(page, hit.file)
|
const next = await spot(page, hit.file)
|
||||||
if (!next) return Number.POSITIVE_INFINITY
|
if (!next) return Number.POSITIVE_INFINITY
|
||||||
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBeLessThanOrEqual(32)
|
.toBeLessThanOrEqual(32)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -49,188 +49,185 @@ async function seedConversation(input: {
|
||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
|
test("slash undo sets revert and restores prior prompt", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `undo_${Date.now()}`
|
const token = `undo_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
const sdk = project.sdk
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.type("/undo")
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
await expect(undo).toBeVisible()
|
await expect(undo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBe(seeded.userMessageID)
|
.toBe(seeded.userMessageID)
|
||||||
|
|
||||||
await expect(seeded.prompt).toContainText(token)
|
await expect(seeded.prompt).toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
|
test("slash redo clears revert and restores latest state", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `redo_${Date.now()}`
|
const token = `redo_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
const sdk = project.sdk
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.type("/undo")
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
await expect(undo).toBeVisible()
|
await expect(undo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBe(seeded.userMessageID)
|
.toBe(seeded.userMessageID)
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
await page.keyboard.press("Backspace")
|
await page.keyboard.press("Backspace")
|
||||||
await page.keyboard.type("/redo")
|
await page.keyboard.type("/redo")
|
||||||
|
|
||||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||||
await expect(redo).toBeVisible()
|
await expect(redo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(seeded.prompt).not.toContainText(token)
|
await expect(seeded.prompt).not.toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
|
test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const firstToken = `undo_redo_first_${Date.now()}`
|
const firstToken = `undo_redo_first_${Date.now()}`
|
||||||
const secondToken = `undo_redo_second_${Date.now()}`
|
const secondToken = `undo_redo_second_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
const sdk = project.sdk
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const first = await seedConversation({
|
const first = await seedConversation({
|
||||||
page,
|
page,
|
||||||
sdk,
|
sdk,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
token: firstToken,
|
token: firstToken,
|
||||||
})
|
|
||||||
const second = await seedConversation({
|
|
||||||
page,
|
|
||||||
sdk,
|
|
||||||
sessionID: session.id,
|
|
||||||
token: secondToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(first.userMessageID).not.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
|
||||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(1)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(first.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(0)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
|
|
||||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBeUndefined()
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(1)
|
|
||||||
})
|
})
|
||||||
|
const second = await seedConversation({
|
||||||
|
page,
|
||||||
|
sdk,
|
||||||
|
sessionID: session.id,
|
||||||
|
token: secondToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(first.userMessageID).not.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||||
|
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(1)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(first.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(0)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
|
||||||
|
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBeUndefined()
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -31,156 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
|
test("session can be renamed via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const renamedTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, originalTitle, async (session) => {
|
await withSession(project.sdk, originalTitle, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await seedMessage(project.sdk, session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await expect(input).toBeFocused()
|
await expect(input).toBeFocused()
|
||||||
await input.fill(renamedTitle)
|
await input.fill(renamedTitle)
|
||||||
await expect(input).toHaveValue(renamedTitle)
|
await expect(input).toHaveValue(renamedTitle)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.title
|
return data?.title
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBe(renamedTitle)
|
.toBe(renamedTitle)
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be archived via header menu", async ({ page, withBackendProject }) => {
|
test("session can be archived via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e archive test ${stamp}`
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await withSession(project.sdk, title, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await seedMessage(project.sdk, session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /archive/i)
|
await clickMenuItem(menu, /archive/i)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.time?.archived
|
return data?.time?.archived
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
|
test("session can be deleted via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e delete test ${stamp}`
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await withSession(project.sdk, title, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await seedMessage(project.sdk, session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /delete/i)
|
await clickMenuItem(menu, /delete/i)
|
||||||
await confirmDialog(page, /delete/i)
|
await confirmDialog(page, /delete/i)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session
|
const data = await project.sdk.session
|
||||||
.get({ sessionID: session.id })
|
.get({ sessionID: session.id })
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
return data?.id
|
return data?.id
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
|
test("session can be shared and unshared via header button", async ({ page, project }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e share test ${stamp}`
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await withSession(project.sdk, title, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await project.gotoSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
await project.prompt(`share seed ${stamp}`)
|
||||||
|
|
||||||
const shared = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
await expect(publish).toBeVisible({ timeout: 30_000 })
|
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||||
await publish.click()
|
await publish.click()
|
||||||
|
|
||||||
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||||
await unpublish.click()
|
await unpublish.click()
|
||||||
|
|
||||||
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
const unshared = await openSharePopover(page)
|
const unshared = await openSharePopover(page)
|
||||||
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,20 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||||
return document.documentElement.getAttribute("data-theme")
|
return document.documentElement.getAttribute("data-theme")
|
||||||
})
|
})
|
||||||
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
|
|
||||||
|
await trigger.click()
|
||||||
|
const open = await expect
|
||||||
|
.poll(async () => (await items.count()) > 0, { timeout: 5_000 })
|
||||||
|
.toBe(true)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!open) {
|
||||||
|
await trigger.click()
|
||||||
|
await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
|
||||||
|
}
|
||||||
|
await expect(items.first()).toBeVisible()
|
||||||
const count = await items.count()
|
const count = await items.count()
|
||||||
expect(count).toBeGreaterThan(1)
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,70 +48,61 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const slug = dirSlug(other)
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async () => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
const card = page.locator('[data-component="hover-card-content"]')
|
const card = page.locator('[data-component="hover-card-content"]')
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
await expect(projectButton).toBeVisible()
|
||||||
await project.hover()
|
await projectButton.hover()
|
||||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||||
|
|
||||||
await page.mouse.down()
|
await projectButton.click()
|
||||||
await expect(card).toHaveCount(0)
|
await expect(card).toHaveCount(0)
|
||||||
await page.mouse.up()
|
|
||||||
|
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
await expect(card).toHaveCount(0)
|
await expect(card).toHaveCount(0)
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const slug = dirSlug(other)
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async () => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
await defocus(page)
|
||||||
await defocus(page)
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
await expect(projectButton).toBeVisible()
|
||||||
|
|
||||||
let hit = false
|
let hit = false
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
hit = await project.evaluate((el) => {
|
hit = await projectButton.evaluate((el) => {
|
||||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||||
})
|
})
|
||||||
if (hit) break
|
if (hit) break
|
||||||
await page.keyboard.press("Tab")
|
await page.keyboard.press("Tab")
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(hit).toBe(true)
|
expect(hit).toBe(true)
|
||||||
|
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,35 +12,34 @@ async function open(page: Page) {
|
||||||
return term
|
return term
|
||||||
}
|
}
|
||||||
|
|
||||||
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
test("terminal reconnects without replacing the pty", async ({ page, project }) => {
|
||||||
await withProject(async ({ gotoSession }) => {
|
await project.open()
|
||||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||||
const token = `E2E_RECONNECT_${Date.now()}`
|
const token = `E2E_RECONNECT_${Date.now()}`
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
const term = await open(page)
|
const term = await open(page)
|
||||||
const id = await term.getAttribute("data-pty-id")
|
const id = await term.getAttribute("data-pty-id")
|
||||||
if (!id) throw new Error("Active terminal missing data-pty-id")
|
if (!id) throw new Error("Active terminal missing data-pty-id")
|
||||||
|
|
||||||
const prev = await terminalConnects(page, { term })
|
const prev = await terminalConnects(page, { term })
|
||||||
|
|
||||||
await runTerminal(page, {
|
await runTerminal(page, {
|
||||||
term,
|
term,
|
||||||
cmd: `export ${name}=${token}; echo ${token}`,
|
cmd: `export ${name}=${token}; echo ${token}`,
|
||||||
token,
|
token,
|
||||||
})
|
})
|
||||||
|
|
||||||
await disconnectTerminal(page, { term })
|
await disconnectTerminal(page, { term })
|
||||||
|
|
||||||
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
||||||
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
||||||
|
|
||||||
await runTerminal(page, {
|
await runTerminal(page, {
|
||||||
term,
|
term,
|
||||||
cmd: `echo $${name}`,
|
cmd: `echo $${name}`,
|
||||||
token,
|
token,
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -36,133 +36,130 @@ async function store(page: Page, key: string) {
|
||||||
}, key)
|
}, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||||
|
|
||||||
await first.click()
|
await first.click()
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
return {
|
return {
|
||||||
first: first.includes(one),
|
first: first.includes(one),
|
||||||
second: second.includes(two),
|
second: second.includes(two),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ first: false, second: true })
|
.toEqual({ first: false, second: true })
|
||||||
|
|
||||||
await second.click()
|
await second.click()
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
return {
|
return {
|
||||||
first: first.includes(one),
|
first: first.includes(one),
|
||||||
second: second.includes(two),
|
second: second.includes(two),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ first: true, second: false })
|
.toEqual({ first: true, second: false })
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
await second.click()
|
await second.click()
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
await second.hover()
|
await second.hover()
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /close terminal/i })
|
.getByRole("button", { name: /close terminal/i })
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.click({ force: true })
|
.click({ force: true })
|
||||||
|
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
await expect(tabs).toHaveCount(1)
|
await expect(tabs).toHaveCount(1)
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
return {
|
return {
|
||||||
count: state?.all.length ?? 0,
|
count: state?.all.length ?? 0,
|
||||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ count: 1, first: true })
|
.toEqual({ count: 1, first: true })
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const rename = `E2E term ${Date.now()}`
|
const rename = `E2E term ${Date.now()}`
|
||||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await expect(tab).toContainText(/Terminal 1/)
|
await expect(tab).toContainText(/Terminal 1/)
|
||||||
await tab.click({ button: "right" })
|
await tab.click({ button: "right" })
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||||
await expect(menu).toHaveCount(0)
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await input.fill(rename)
|
await input.fill(rename)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect(input).toHaveCount(0)
|
await expect(input).toHaveCount(0)
|
||||||
await expect(tab).toContainText(rename)
|
await expect(tab).toContainText(rename)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
return state?.all[0]?.title
|
return state?.all[0]?.title
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toBe(rename)
|
.toBe(rename)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission"
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { promptProbe } from "@/testing/prompt"
|
||||||
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 { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
|
|
@ -307,6 +308,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
||||||
input.addToHistory(currentPrompt, mode)
|
input.addToHistory(currentPrompt, mode)
|
||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
promptProbe.start()
|
||||||
|
|
||||||
const projectDirectory = sdk.directory
|
const projectDirectory = sdk.directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
|
|
@ -426,6 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
|
||||||
input.onSubmit?.()
|
input.onSubmit?.()
|
||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ export type PromptProbeState = {
|
||||||
selects: number
|
selects: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptSendState = {
|
||||||
|
started: number
|
||||||
|
count: number
|
||||||
|
sessionID?: string
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const promptEnabled = () => {
|
export const promptEnabled = () => {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return false
|
||||||
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
||||||
|
|
@ -53,4 +60,24 @@ export const promptProbe = {
|
||||||
if (!state) return
|
if (!state) return
|
||||||
state.current = undefined
|
state.current = undefined
|
||||||
},
|
},
|
||||||
|
start() {
|
||||||
|
const state = root()
|
||||||
|
if (!state) return
|
||||||
|
state.sent = {
|
||||||
|
started: (state.sent?.started ?? 0) + 1,
|
||||||
|
count: state.sent?.count ?? 0,
|
||||||
|
sessionID: state.sent?.sessionID,
|
||||||
|
directory: state.sent?.directory,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submit(input: { sessionID: string; directory: string }) {
|
||||||
|
const state = root()
|
||||||
|
if (!state) return
|
||||||
|
state.sent = {
|
||||||
|
started: state.sent?.started ?? 0,
|
||||||
|
count: (state.sent?.count ?? 0) + 1,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
directory: input.directory,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export type E2EWindow = Window & {
|
||||||
prompt?: {
|
prompt?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
current?: import("./prompt").PromptProbeState
|
current?: import("./prompt").PromptProbeState
|
||||||
|
sent?: import("./prompt").PromptSendState
|
||||||
}
|
}
|
||||||
terminal?: {
|
terminal?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,14 @@ export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconMiMo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0C8.016 0 4.756.255 2.493 2.516.23 4.776 0 8.033 0 12.012c0 3.98.23 7.235 2.494 9.497C4.757 23.77 8.017 24 12 24c3.983 0 7.243-.23 9.506-2.491C23.77 19.247 24 15.99 24 12.012c0-3.984-.233-7.243-2.502-9.504C19.234.252 15.978 0 12 0zM4.906 7.405h5.624c1.47 0 3.007.068 3.764.827.746.746.827 2.233.83 3.676v4.54a.15.15 0 0 1-.152.147h-1.947a.15.15 0 0 1-.152-.148V11.83c-.002-.806-.048-1.634-.464-2.051-.358-.36-1.026-.441-1.72-.458H7.158a.15.15 0 0 0-.151.147v6.98a.15.15 0 0 1-.152.148H4.906a.15.15 0 0 1-.15-.148V7.554a.15.15 0 0 1 .15-.149zm12.131 0h1.949a.15.15 0 0 1 .15.15v8.892a.15.15 0 0 1-.15.148h-1.949a.15.15 0 0 1-.151-.148V7.554a.15.15 0 0 1 .151-.149zM8.92 10.948h2.046c.083 0 .15.066.15.147v5.352a.15.15 0 0 1-.15.148H8.92a.15.15 0 0 1-.152-.148v-5.352a.15.15 0 0 1 .152-.147Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function IconXiaomi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
export function IconXiaomi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
|
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni و MiniMax M2.5 وMiniMax M2.7.",
|
||||||
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
|
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
|
||||||
|
|
@ -297,7 +297,7 @@ export const dict = {
|
||||||
"go.problem.item1": "أسعار اشتراك منخفضة التكلفة",
|
"go.problem.item1": "أسعار اشتراك منخفضة التكلفة",
|
||||||
"go.problem.item2": "حدود سخية ووصول موثوق",
|
"go.problem.item2": "حدود سخية ووصول موثوق",
|
||||||
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
|
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
|
||||||
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7",
|
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7",
|
||||||
"go.how.title": "كيف يعمل Go",
|
"go.how.title": "كيف يعمل Go",
|
||||||
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
|
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
|
||||||
"go.how.step1.title": "أنشئ حسابًا",
|
"go.how.step1.title": "أنشئ حسابًا",
|
||||||
|
|
@ -318,10 +318,11 @@ export const dict = {
|
||||||
"go.faq.q1": "ما هو OpenCode Go؟",
|
"go.faq.q1": "ما هو OpenCode Go؟",
|
||||||
"go.faq.a1": "Go هو اشتراك منخفض التكلفة يمنحك وصولًا موثوقًا إلى نماذج مفتوحة المصدر قادرة على البرمجة الوكيلة.",
|
"go.faq.a1": "Go هو اشتراك منخفض التكلفة يمنحك وصولًا موثوقًا إلى نماذج مفتوحة المصدر قادرة على البرمجة الوكيلة.",
|
||||||
"go.faq.q2": "ما النماذج التي يتضمنها Go؟",
|
"go.faq.q2": "ما النماذج التي يتضمنها Go؟",
|
||||||
"go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
|
"go.faq.a2":
|
||||||
|
"يتضمن Go نماذج GLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
|
||||||
"go.faq.q3": "هل Go هو نفسه Zen؟",
|
"go.faq.q3": "هل Go هو نفسه Zen؟",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
|
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni و MiniMax M2.5 وMiniMax M2.7.",
|
||||||
"go.faq.q4": "كم تكلفة Go؟",
|
"go.faq.q4": "كم تكلفة Go؟",
|
||||||
"go.faq.a4.p1.beforePricing": "تكلفة Go",
|
"go.faq.a4.p1.beforePricing": "تكلفة Go",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
|
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
|
||||||
|
|
@ -344,7 +345,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
|
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||||
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
|
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.",
|
||||||
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
|
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
|
||||||
|
|
@ -302,7 +302,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Preço de assinatura de baixo custo",
|
"go.problem.item1": "Preço de assinatura de baixo custo",
|
||||||
"go.problem.item2": "Limites generosos e acesso confiável",
|
"go.problem.item2": "Limites generosos e acesso confiável",
|
||||||
"go.problem.item3": "Feito para o maior número possível de programadores",
|
"go.problem.item3": "Feito para o maior número possível de programadores",
|
||||||
"go.problem.item4": "Inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
|
"go.problem.item4": "Inclui GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7",
|
||||||
"go.how.title": "Como o Go funciona",
|
"go.how.title": "Como o Go funciona",
|
||||||
"go.how.body":
|
"go.how.body":
|
||||||
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
|
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
|
||||||
|
|
@ -325,10 +325,11 @@ export const dict = {
|
||||||
"go.faq.a1":
|
"go.faq.a1":
|
||||||
"Go é uma assinatura de baixo custo que oferece acesso confiável a modelos de código aberto capazes para codificação com agentes.",
|
"Go é uma assinatura de baixo custo que oferece acesso confiável a modelos de código aberto capazes para codificação com agentes.",
|
||||||
"go.faq.q2": "Quais modelos o Go inclui?",
|
"go.faq.q2": "Quais modelos o Go inclui?",
|
||||||
"go.faq.a2": "Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, com limites generosos e acesso confiável.",
|
"go.faq.a2":
|
||||||
|
"Go inclui GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7, com limites generosos e acesso confiável.",
|
||||||
"go.faq.q3": "O Go é o mesmo que o Zen?",
|
"go.faq.q3": "O Go é o mesmo que o Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.",
|
||||||
"go.faq.q4": "Quanto custa o Go?",
|
"go.faq.q4": "Quanto custa o Go?",
|
||||||
"go.faq.a4.p1.beforePricing": "O Go custa",
|
"go.faq.a4.p1.beforePricing": "O Go custa",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
|
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
|
||||||
|
|
@ -352,7 +353,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
|
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
||||||
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
|
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.",
|
||||||
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
|
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
|
||||||
|
|
@ -299,7 +299,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Lavpris abonnementspriser",
|
"go.problem.item1": "Lavpris abonnementspriser",
|
||||||
"go.problem.item2": "Generøse grænser og pålidelig adgang",
|
"go.problem.item2": "Generøse grænser og pålidelig adgang",
|
||||||
"go.problem.item3": "Bygget til så mange programmører som muligt",
|
"go.problem.item3": "Bygget til så mange programmører som muligt",
|
||||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
|
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7",
|
||||||
"go.how.title": "Hvordan Go virker",
|
"go.how.title": "Hvordan Go virker",
|
||||||
"go.how.body":
|
"go.how.body":
|
||||||
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
|
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
|
||||||
|
|
@ -323,10 +323,10 @@ export const dict = {
|
||||||
"Go er et lavprisabonnement, der giver dig pålidelig adgang til kapable open source-modeller til agentisk kodning.",
|
"Go er et lavprisabonnement, der giver dig pålidelig adgang til kapable open source-modeller til agentisk kodning.",
|
||||||
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
||||||
"go.faq.a2":
|
"go.faq.a2":
|
||||||
"Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med generøse grænser og pålidelig adgang.",
|
"Go inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7, med generøse grænser og pålidelig adgang.",
|
||||||
"go.faq.q3": "Er Go det samme som Zen?",
|
"go.faq.q3": "Er Go det samme som Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.",
|
||||||
"go.faq.q4": "Hvad koster Go?",
|
"go.faq.q4": "Hvad koster Go?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||||
|
|
@ -349,7 +349,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
|
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
||||||
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
|
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
|
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7.",
|
||||||
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
|
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
|
||||||
|
|
@ -301,7 +301,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Kostengünstiges Abonnement",
|
"go.problem.item1": "Kostengünstiges Abonnement",
|
||||||
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
|
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
|
||||||
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
|
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
|
||||||
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7",
|
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7",
|
||||||
"go.how.title": "Wie Go funktioniert",
|
"go.how.title": "Wie Go funktioniert",
|
||||||
"go.how.body":
|
"go.how.body":
|
||||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
|
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
|
||||||
|
|
@ -325,10 +325,10 @@ export const dict = {
|
||||||
"Go ist ein kostengünstiges Abonnement, das dir zuverlässigen Zugang zu leistungsfähigen Open-Source-Modellen für Agentic Coding bietet.",
|
"Go ist ein kostengünstiges Abonnement, das dir zuverlässigen Zugang zu leistungsfähigen Open-Source-Modellen für Agentic Coding bietet.",
|
||||||
"go.faq.q2": "Welche Modelle beinhaltet Go?",
|
"go.faq.q2": "Welche Modelle beinhaltet Go?",
|
||||||
"go.faq.a2":
|
"go.faq.a2":
|
||||||
"Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7, mit großzügigen Limits und zuverlässigem Zugang.",
|
"Go beinhaltet GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7, mit großzügigen Limits und zuverlässigem Zugang.",
|
||||||
"go.faq.q3": "Ist Go dasselbe wie Zen?",
|
"go.faq.q3": "Ist Go dasselbe wie Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
|
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7.",
|
||||||
"go.faq.q4": "Wie viel kostet Go?",
|
"go.faq.q4": "Wie viel kostet Go?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go kostet",
|
"go.faq.a4.p1.beforePricing": "Go kostet",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
|
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
|
||||||
|
|
@ -352,7 +352,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
|
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
||||||
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
|
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7.",
|
||||||
"go.hero.title": "Low cost coding models for everyone",
|
"go.hero.title": "Low cost coding models for everyone",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
|
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
|
||||||
|
|
@ -295,7 +295,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Low cost subscription pricing",
|
"go.problem.item1": "Low cost subscription pricing",
|
||||||
"go.problem.item2": "Generous limits and reliable access",
|
"go.problem.item2": "Generous limits and reliable access",
|
||||||
"go.problem.item3": "Built for as many programmers as possible",
|
"go.problem.item3": "Built for as many programmers as possible",
|
||||||
"go.problem.item4": "Includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7",
|
"go.problem.item4": "Includes GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7",
|
||||||
"go.how.title": "How Go works",
|
"go.how.title": "How Go works",
|
||||||
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
|
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
|
||||||
"go.how.step1.title": "Create an account",
|
"go.how.step1.title": "Create an account",
|
||||||
|
|
@ -318,10 +318,10 @@ export const dict = {
|
||||||
"Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.",
|
"Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.",
|
||||||
"go.faq.q2": "What models does Go include?",
|
"go.faq.q2": "What models does Go include?",
|
||||||
"go.faq.a2":
|
"go.faq.a2":
|
||||||
"Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7, with generous limits and reliable access.",
|
"Go includes GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7, with generous limits and reliable access.",
|
||||||
"go.faq.q3": "Is Go the same as Zen?",
|
"go.faq.q3": "Is Go the same as Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
|
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7.",
|
||||||
"go.faq.q4": "How much does Go cost?",
|
"go.faq.q4": "How much does Go cost?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go costs",
|
"go.faq.a4.p1.beforePricing": "Go costs",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 first month",
|
"go.faq.a4.p1.pricingLink": "$5 first month",
|
||||||
|
|
@ -345,7 +345,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "What is the difference between free models and Go?",
|
"go.faq.q9": "What is the difference between free models and Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||||
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
|
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7.",
|
||||||
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
|
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
|
||||||
|
|
@ -303,7 +303,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Precios de suscripción de bajo coste",
|
"go.problem.item1": "Precios de suscripción de bajo coste",
|
||||||
"go.problem.item2": "Límites generosos y acceso fiable",
|
"go.problem.item2": "Límites generosos y acceso fiable",
|
||||||
"go.problem.item3": "Creado para tantos programadores como sea posible",
|
"go.problem.item3": "Creado para tantos programadores como sea posible",
|
||||||
"go.problem.item4": "Incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7",
|
"go.problem.item4": "Incluye GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7",
|
||||||
"go.how.title": "Cómo funciona Go",
|
"go.how.title": "Cómo funciona Go",
|
||||||
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
|
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
|
||||||
"go.how.step1.title": "Crear una cuenta",
|
"go.how.step1.title": "Crear una cuenta",
|
||||||
|
|
@ -325,10 +325,11 @@ export const dict = {
|
||||||
"go.faq.a1":
|
"go.faq.a1":
|
||||||
"Go es una suscripción de bajo coste que te da acceso fiable a modelos de código abierto capaces para programación agéntica.",
|
"Go es una suscripción de bajo coste que te da acceso fiable a modelos de código abierto capaces para programación agéntica.",
|
||||||
"go.faq.q2": "¿Qué modelos incluye Go?",
|
"go.faq.q2": "¿Qué modelos incluye Go?",
|
||||||
"go.faq.a2": "Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7, con límites generosos y acceso fiable.",
|
"go.faq.a2":
|
||||||
|
"Go incluye GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7, con límites generosos y acceso fiable.",
|
||||||
"go.faq.q3": "¿Es Go lo mismo que Zen?",
|
"go.faq.q3": "¿Es Go lo mismo que Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
|
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7.",
|
||||||
"go.faq.q4": "¿Cuánto cuesta Go?",
|
"go.faq.q4": "¿Cuánto cuesta Go?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go cuesta",
|
"go.faq.a4.p1.beforePricing": "Go cuesta",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
|
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
|
||||||
|
|
@ -352,7 +353,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
|
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
|
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
|
||||||
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
|
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
|
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
|
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7.",
|
||||||
"go.hero.title": "Modèles de code à faible coût pour tous",
|
"go.hero.title": "Modèles de code à faible coût pour tous",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.",
|
"Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.",
|
||||||
|
|
@ -303,7 +303,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Prix d'abonnement bas",
|
"go.problem.item1": "Prix d'abonnement bas",
|
||||||
"go.problem.item2": "Limites généreuses et accès fiable",
|
"go.problem.item2": "Limites généreuses et accès fiable",
|
||||||
"go.problem.item3": "Conçu pour autant de programmeurs que possible",
|
"go.problem.item3": "Conçu pour autant de programmeurs que possible",
|
||||||
"go.problem.item4": "Inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7",
|
"go.problem.item4": "Inclut GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7",
|
||||||
"go.how.title": "Comment fonctionne Go",
|
"go.how.title": "Comment fonctionne Go",
|
||||||
"go.how.body":
|
"go.how.body":
|
||||||
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
|
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
|
||||||
|
|
@ -327,10 +327,10 @@ export const dict = {
|
||||||
"Go est un abonnement à faible coût qui vous donne un accès fiable à des modèles open source performants pour le codage agentique.",
|
"Go est un abonnement à faible coût qui vous donne un accès fiable à des modèles open source performants pour le codage agentique.",
|
||||||
"go.faq.q2": "Quels modèles Go inclut-il ?",
|
"go.faq.q2": "Quels modèles Go inclut-il ?",
|
||||||
"go.faq.a2":
|
"go.faq.a2":
|
||||||
"Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7, avec des limites généreuses et un accès fiable.",
|
"Go inclut GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7, avec des limites généreuses et un accès fiable.",
|
||||||
"go.faq.q3": "Est-ce que Go est la même chose que Zen ?",
|
"go.faq.q3": "Est-ce que Go est la même chose que Zen ?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
|
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7.",
|
||||||
"go.faq.q4": "Combien coûte Go ?",
|
"go.faq.q4": "Combien coûte Go ?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go coûte",
|
"go.faq.a4.p1.beforePricing": "Go coûte",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 le premier mois",
|
"go.faq.a4.p1.pricingLink": "$5 le premier mois",
|
||||||
|
|
@ -353,7 +353,7 @@ export const dict = {
|
||||||
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
|
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
|
||||||
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
|
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
|
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
|
||||||
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
|
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
|
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.",
|
||||||
"go.hero.title": "Modelli di coding a basso costo per tutti",
|
"go.hero.title": "Modelli di coding a basso costo per tutti",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.",
|
"Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.",
|
||||||
|
|
@ -299,7 +299,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Prezzo di abbonamento a basso costo",
|
"go.problem.item1": "Prezzo di abbonamento a basso costo",
|
||||||
"go.problem.item2": "Limiti generosi e accesso affidabile",
|
"go.problem.item2": "Limiti generosi e accesso affidabile",
|
||||||
"go.problem.item3": "Costruito per il maggior numero possibile di programmatori",
|
"go.problem.item3": "Costruito per il maggior numero possibile di programmatori",
|
||||||
"go.problem.item4": "Include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
|
"go.problem.item4": "Include GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7",
|
||||||
"go.how.title": "Come funziona Go",
|
"go.how.title": "Come funziona Go",
|
||||||
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
|
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
|
||||||
"go.how.step1.title": "Crea un account",
|
"go.how.step1.title": "Crea un account",
|
||||||
|
|
@ -321,10 +321,11 @@ export const dict = {
|
||||||
"go.faq.a1":
|
"go.faq.a1":
|
||||||
"Go è un abbonamento a basso costo che ti dà un accesso affidabile a modelli open source capaci per il coding agentico.",
|
"Go è un abbonamento a basso costo che ti dà un accesso affidabile a modelli open source capaci per il coding agentico.",
|
||||||
"go.faq.q2": "Quali modelli include Go?",
|
"go.faq.q2": "Quali modelli include Go?",
|
||||||
"go.faq.a2": "Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, con limiti generosi e accesso affidabile.",
|
"go.faq.a2":
|
||||||
|
"Go include GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7, con limiti generosi e accesso affidabile.",
|
||||||
"go.faq.q3": "Go è lo stesso di Zen?",
|
"go.faq.q3": "Go è lo stesso di Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.",
|
||||||
"go.faq.q4": "Quanto costa Go?",
|
"go.faq.q4": "Quanto costa Go?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go costa",
|
"go.faq.a4.p1.beforePricing": "Go costa",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 il primo mese",
|
"go.faq.a4.p1.pricingLink": "$5 il primo mese",
|
||||||
|
|
@ -348,7 +349,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
|
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
|
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
|
||||||
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
|
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
|
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7に対して5時間のゆとりあるリクエスト上限があります。",
|
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7に対して5時間のゆとりあるリクエスト上限があります。",
|
||||||
"go.hero.title": "すべての人のための低価格なコーディングモデル",
|
"go.hero.title": "すべての人のための低価格なコーディングモデル",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。",
|
"Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。",
|
||||||
|
|
@ -299,7 +299,7 @@ export const dict = {
|
||||||
"go.problem.item1": "低価格なサブスクリプション料金",
|
"go.problem.item1": "低価格なサブスクリプション料金",
|
||||||
"go.problem.item2": "十分な制限と安定したアクセス",
|
"go.problem.item2": "十分な制限と安定したアクセス",
|
||||||
"go.problem.item3": "できるだけ多くのプログラマーのために構築",
|
"go.problem.item3": "できるだけ多くのプログラマーのために構築",
|
||||||
"go.problem.item4": "GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7を含む",
|
"go.problem.item4": "GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7を含む",
|
||||||
"go.how.title": "Goの仕組み",
|
"go.how.title": "Goの仕組み",
|
||||||
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
|
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
|
||||||
"go.how.step1.title": "アカウントを作成",
|
"go.how.step1.title": "アカウントを作成",
|
||||||
|
|
@ -322,10 +322,10 @@ export const dict = {
|
||||||
"Goは、エージェント型コーディングのための有能なオープンソースモデルへの安定したアクセスを提供する低価格なサブスクリプションです。",
|
"Goは、エージェント型コーディングのための有能なオープンソースモデルへの安定したアクセスを提供する低価格なサブスクリプションです。",
|
||||||
"go.faq.q2": "Goにはどのモデルが含まれますか?",
|
"go.faq.q2": "Goにはどのモデルが含まれますか?",
|
||||||
"go.faq.a2":
|
"go.faq.a2":
|
||||||
"Goには、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれており、十分な制限と安定したアクセスが提供されます。",
|
"Goには、GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7が含まれており、十分な制限と安定したアクセスが提供されます。",
|
||||||
"go.faq.q3": "GoはZenと同じですか?",
|
"go.faq.q3": "GoはZenと同じですか?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||||
"go.faq.q4": "Goの料金は?",
|
"go.faq.q4": "Goの料金は?",
|
||||||
"go.faq.a4.p1.beforePricing": "Goは",
|
"go.faq.a4.p1.beforePricing": "Goは",
|
||||||
"go.faq.a4.p1.pricingLink": "最初の月$5",
|
"go.faq.a4.p1.pricingLink": "最初の月$5",
|
||||||
|
|
@ -349,7 +349,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "無料モデルとGoの違いは何ですか?",
|
"go.faq.q9": "無料モデルとGoの違いは何ですか?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
|
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
|
||||||
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
|
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
|
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||||
"go.hero.title": "모두를 위한 저비용 코딩 모델",
|
"go.hero.title": "모두를 위한 저비용 코딩 모델",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.",
|
"Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.",
|
||||||
|
|
@ -296,7 +296,7 @@ export const dict = {
|
||||||
"go.problem.item1": "저렴한 구독 가격",
|
"go.problem.item1": "저렴한 구독 가격",
|
||||||
"go.problem.item2": "넉넉한 한도와 안정적인 액세스",
|
"go.problem.item2": "넉넉한 한도와 안정적인 액세스",
|
||||||
"go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨",
|
"go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨",
|
||||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 포함",
|
"go.problem.item4": "GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7 포함",
|
||||||
"go.how.title": "Go 작동 방식",
|
"go.how.title": "Go 작동 방식",
|
||||||
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
|
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
|
||||||
"go.how.step1.title": "계정 생성",
|
"go.how.step1.title": "계정 생성",
|
||||||
|
|
@ -318,10 +318,10 @@ export const dict = {
|
||||||
"go.faq.a1": "Go는 에이전트 코딩을 위한 유능한 오픈 소스 모델에 대해 안정적인 액세스를 제공하는 저비용 구독입니다.",
|
"go.faq.a1": "Go는 에이전트 코딩을 위한 유능한 오픈 소스 모델에 대해 안정적인 액세스를 제공하는 저비용 구독입니다.",
|
||||||
"go.faq.q2": "Go에는 어떤 모델이 포함되나요?",
|
"go.faq.q2": "Go에는 어떤 모델이 포함되나요?",
|
||||||
"go.faq.a2":
|
"go.faq.a2":
|
||||||
"Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7가 포함됩니다.",
|
"Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7가 포함됩니다.",
|
||||||
"go.faq.q3": "Go는 Zen과 같은가요?",
|
"go.faq.q3": "Go는 Zen과 같은가요?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||||
"go.faq.q4": "Go 비용은 얼마인가요?",
|
"go.faq.q4": "Go 비용은 얼마인가요?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go 비용은",
|
"go.faq.a4.p1.beforePricing": "Go 비용은",
|
||||||
"go.faq.a4.p1.pricingLink": "첫 달 $5",
|
"go.faq.a4.p1.pricingLink": "첫 달 $5",
|
||||||
|
|
@ -344,7 +344,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
|
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
|
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
|
||||||
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
|
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
|
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.",
|
||||||
"go.hero.title": "Rimelige kodemodeller for alle",
|
"go.hero.title": "Rimelige kodemodeller for alle",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.",
|
"Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.",
|
||||||
|
|
@ -299,7 +299,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Rimelig abonnementspris",
|
"go.problem.item1": "Rimelig abonnementspris",
|
||||||
"go.problem.item2": "Rause grenser og pålitelig tilgang",
|
"go.problem.item2": "Rause grenser og pålitelig tilgang",
|
||||||
"go.problem.item3": "Bygget for så mange programmerere som mulig",
|
"go.problem.item3": "Bygget for så mange programmerere som mulig",
|
||||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
|
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7",
|
||||||
"go.how.title": "Hvordan Go fungerer",
|
"go.how.title": "Hvordan Go fungerer",
|
||||||
"go.how.body":
|
"go.how.body":
|
||||||
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
|
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
|
||||||
|
|
@ -322,10 +322,11 @@ export const dict = {
|
||||||
"go.faq.a1":
|
"go.faq.a1":
|
||||||
"Go er et rimelig abonnement som gir deg pålitelig tilgang til kapable åpen kildekode-modeller for agent-koding.",
|
"Go er et rimelig abonnement som gir deg pålitelig tilgang til kapable åpen kildekode-modeller for agent-koding.",
|
||||||
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
||||||
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med rause grenser og pålitelig tilgang.",
|
"go.faq.a2":
|
||||||
|
"Go inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7, med rause grenser og pålitelig tilgang.",
|
||||||
"go.faq.q3": "Er Go det samme som Zen?",
|
"go.faq.q3": "Er Go det samme som Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.",
|
||||||
"go.faq.q4": "Hva koster Go?",
|
"go.faq.q4": "Hva koster Go?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||||
|
|
@ -349,7 +350,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
|
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
|
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
|
||||||
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
|
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
|
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
|
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7.",
|
||||||
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
|
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.",
|
"Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.",
|
||||||
|
|
@ -300,7 +300,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Niskokosztowa cena subskrypcji",
|
"go.problem.item1": "Niskokosztowa cena subskrypcji",
|
||||||
"go.problem.item2": "Hojne limity i niezawodny dostęp",
|
"go.problem.item2": "Hojne limity i niezawodny dostęp",
|
||||||
"go.problem.item3": "Stworzony dla jak największej liczby programistów",
|
"go.problem.item3": "Stworzony dla jak największej liczby programistów",
|
||||||
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7",
|
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7",
|
||||||
"go.how.title": "Jak działa Go",
|
"go.how.title": "Jak działa Go",
|
||||||
"go.how.body":
|
"go.how.body":
|
||||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
|
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
|
||||||
|
|
@ -323,10 +323,11 @@ export const dict = {
|
||||||
"go.faq.a1":
|
"go.faq.a1":
|
||||||
"Go to niskokosztowa subskrypcja, która daje niezawodny dostęp do zdolnych modeli open source dla agentów kodujących.",
|
"Go to niskokosztowa subskrypcja, która daje niezawodny dostęp do zdolnych modeli open source dla agentów kodujących.",
|
||||||
"go.faq.q2": "Jakie modele zawiera Go?",
|
"go.faq.q2": "Jakie modele zawiera Go?",
|
||||||
"go.faq.a2": "Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7, z hojnymi limitami i niezawodnym dostępem.",
|
"go.faq.a2":
|
||||||
|
"Go zawiera GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7, z hojnymi limitami i niezawodnym dostępem.",
|
||||||
"go.faq.q3": "Czy Go to to samo co Zen?",
|
"go.faq.q3": "Czy Go to to samo co Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
|
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7.",
|
||||||
"go.faq.q4": "Ile kosztuje Go?",
|
"go.faq.q4": "Ile kosztuje Go?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go kosztuje",
|
"go.faq.a4.p1.beforePricing": "Go kosztuje",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc",
|
"go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc",
|
||||||
|
|
@ -350,7 +351,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
|
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
|
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
|
||||||
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
|
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
|
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
|
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7.",
|
||||||
"go.hero.title": "Недорогие модели для кодинга для всех",
|
"go.hero.title": "Недорогие модели для кодинга для всех",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.",
|
"Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.",
|
||||||
|
|
@ -304,7 +304,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Недорогая подписка",
|
"go.problem.item1": "Недорогая подписка",
|
||||||
"go.problem.item2": "Щедрые лимиты и надежный доступ",
|
"go.problem.item2": "Щедрые лимиты и надежный доступ",
|
||||||
"go.problem.item3": "Создан для максимального числа программистов",
|
"go.problem.item3": "Создан для максимального числа программистов",
|
||||||
"go.problem.item4": "Включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7",
|
"go.problem.item4": "Включает GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7",
|
||||||
"go.how.title": "Как работает Go",
|
"go.how.title": "Как работает Go",
|
||||||
"go.how.body":
|
"go.how.body":
|
||||||
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
|
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
|
||||||
|
|
@ -327,10 +327,11 @@ export const dict = {
|
||||||
"go.faq.a1":
|
"go.faq.a1":
|
||||||
"Go — это недорогая подписка, дающая надежный доступ к мощным моделям с открытым исходным кодом для агентов-программистов.",
|
"Go — это недорогая подписка, дающая надежный доступ к мощным моделям с открытым исходным кодом для агентов-программистов.",
|
||||||
"go.faq.q2": "Какие модели включает Go?",
|
"go.faq.q2": "Какие модели включает Go?",
|
||||||
"go.faq.a2": "Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7, с щедрыми лимитами и надежным доступом.",
|
"go.faq.a2":
|
||||||
|
"Go включает GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7, с щедрыми лимитами и надежным доступом.",
|
||||||
"go.faq.q3": "Go — это то же самое, что и Zen?",
|
"go.faq.q3": "Go — это то же самое, что и Zen?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
|
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7.",
|
||||||
"go.faq.q4": "Сколько стоит Go?",
|
"go.faq.q4": "Сколько стоит Go?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go стоит",
|
"go.faq.a4.p1.beforePricing": "Go стоит",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 за первый месяц",
|
"go.faq.a4.p1.pricingLink": "$5 за первый месяц",
|
||||||
|
|
@ -354,7 +355,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
|
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
|
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
|
||||||
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
|
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
|
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7",
|
||||||
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน",
|
"Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน",
|
||||||
|
|
@ -297,7 +297,7 @@ export const dict = {
|
||||||
"go.problem.item1": "ราคาการสมัครสมาชิกที่ต่ำ",
|
"go.problem.item1": "ราคาการสมัครสมาชิกที่ต่ำ",
|
||||||
"go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
"go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||||
"go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้",
|
"go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้",
|
||||||
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
|
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7",
|
||||||
"go.how.title": "Go ทำงานอย่างไร",
|
"go.how.title": "Go ทำงานอย่างไร",
|
||||||
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
|
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
|
||||||
"go.how.step1.title": "สร้างบัญชี",
|
"go.how.step1.title": "สร้างบัญชี",
|
||||||
|
|
@ -320,10 +320,10 @@ export const dict = {
|
||||||
"Go คือการสมัครสมาชิกราคาประหยัดที่ให้คุณเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสำหรับการเขียนโค้ดแบบเอเจนต์ได้อย่างน่าเชื่อถือ",
|
"Go คือการสมัครสมาชิกราคาประหยัดที่ให้คุณเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสำหรับการเขียนโค้ดแบบเอเจนต์ได้อย่างน่าเชื่อถือ",
|
||||||
"go.faq.q2": "Go รวมโมเดลอะไรบ้าง?",
|
"go.faq.q2": "Go รวมโมเดลอะไรบ้าง?",
|
||||||
"go.faq.a2":
|
"go.faq.a2":
|
||||||
"Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
"Go รวมถึง GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||||
"go.faq.q3": "Go เหมือนกับ Zen หรือไม่?",
|
"go.faq.q3": "Go เหมือนกับ Zen หรือไม่?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 อย่างเชื่อถือได้",
|
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7 อย่างเชื่อถือได้",
|
||||||
"go.faq.q4": "Go ราคาเท่าไหร่?",
|
"go.faq.q4": "Go ราคาเท่าไหร่?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go ราคา",
|
"go.faq.a4.p1.beforePricing": "Go ราคา",
|
||||||
"go.faq.a4.p1.pricingLink": "$5 เดือนแรก",
|
"go.faq.a4.p1.pricingLink": "$5 เดือนแรก",
|
||||||
|
|
@ -346,7 +346,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
|
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
|
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
|
||||||
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
|
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
|
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 için cömert 5 saatlik istek limitleri sunar.",
|
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 için cömert 5 saatlik istek limitleri sunar.",
|
||||||
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
|
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.",
|
"Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.",
|
||||||
|
|
@ -302,7 +302,7 @@ export const dict = {
|
||||||
"go.problem.item1": "Düşük maliyetli abonelik fiyatlandırması",
|
"go.problem.item1": "Düşük maliyetli abonelik fiyatlandırması",
|
||||||
"go.problem.item2": "Cömert limitler ve güvenilir erişim",
|
"go.problem.item2": "Cömert limitler ve güvenilir erişim",
|
||||||
"go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi",
|
"go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi",
|
||||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 içerir",
|
"go.problem.item4": "GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 içerir",
|
||||||
"go.how.title": "Go nasıl çalışır?",
|
"go.how.title": "Go nasıl çalışır?",
|
||||||
"go.how.body":
|
"go.how.body":
|
||||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
|
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
|
||||||
|
|
@ -326,10 +326,10 @@ export const dict = {
|
||||||
"Go, ajan tabanlı kodlama için yetenekli açık kaynaklı modellere güvenilir erişim sağlayan düşük maliyetli bir aboneliktir.",
|
"Go, ajan tabanlı kodlama için yetenekli açık kaynaklı modellere güvenilir erişim sağlayan düşük maliyetli bir aboneliktir.",
|
||||||
"go.faq.q2": "Go hangi modelleri içerir?",
|
"go.faq.q2": "Go hangi modelleri içerir?",
|
||||||
"go.faq.a2":
|
"go.faq.a2":
|
||||||
"Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini içerir.",
|
"Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 modellerini içerir.",
|
||||||
"go.faq.q3": "Go, Zen ile aynı mı?",
|
"go.faq.q3": "Go, Zen ile aynı mı?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||||
"go.faq.q4": "Go ne kadar?",
|
"go.faq.q4": "Go ne kadar?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go'nun maliyeti",
|
"go.faq.a4.p1.beforePricing": "Go'nun maliyeti",
|
||||||
"go.faq.a4.p1.pricingLink": "İlk ay $5",
|
"go.faq.a4.p1.pricingLink": "İlk ay $5",
|
||||||
|
|
@ -353,7 +353,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
|
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
|
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
|
||||||
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
|
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
|
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go 首月 $5,之后 $10/月,提供对 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小时充裕请求额度。",
|
"Go 首月 $5,之后 $10/月,提供对 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 和 MiniMax M2.7 的 5 小时充裕请求额度。",
|
||||||
"go.hero.title": "人人可用的低成本编程模型",
|
"go.hero.title": "人人可用的低成本编程模型",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。",
|
"Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。",
|
||||||
|
|
@ -288,7 +288,7 @@ export const dict = {
|
||||||
"go.problem.item1": "低成本订阅定价",
|
"go.problem.item1": "低成本订阅定价",
|
||||||
"go.problem.item2": "充裕的限额和可靠的访问",
|
"go.problem.item2": "充裕的限额和可靠的访问",
|
||||||
"go.problem.item3": "为尽可能多的程序员打造",
|
"go.problem.item3": "为尽可能多的程序员打造",
|
||||||
"go.problem.item4": "包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7",
|
"go.problem.item4": "包含 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 和 MiniMax M2.7",
|
||||||
"go.how.title": "Go 如何工作",
|
"go.how.title": "Go 如何工作",
|
||||||
"go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
|
"go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
|
||||||
"go.how.step1.title": "创建账户",
|
"go.how.step1.title": "创建账户",
|
||||||
|
|
@ -307,10 +307,11 @@ export const dict = {
|
||||||
"go.faq.q1": "什么是 OpenCode Go?",
|
"go.faq.q1": "什么是 OpenCode Go?",
|
||||||
"go.faq.a1": "Go 是一项低成本订阅服务,为您提供对强大的开源模型的可靠访问,用于代理编程。",
|
"go.faq.a1": "Go 是一项低成本订阅服务,为您提供对强大的开源模型的可靠访问,用于代理编程。",
|
||||||
"go.faq.q2": "Go 包含哪些模型?",
|
"go.faq.q2": "Go 包含哪些模型?",
|
||||||
"go.faq.a2": "Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并提供充裕的限额和可靠的访问。",
|
"go.faq.a2":
|
||||||
|
"Go 包含 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 和 MiniMax M2.7,并提供充裕的限额和可靠的访问。",
|
||||||
"go.faq.q3": "Go 和 Zen 一样吗?",
|
"go.faq.q3": "Go 和 Zen 一样吗?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等开源模型。",
|
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 和 MiniMax M2.7 等开源模型。",
|
||||||
"go.faq.q4": "Go 多少钱?",
|
"go.faq.q4": "Go 多少钱?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go 费用为",
|
"go.faq.a4.p1.beforePricing": "Go 费用为",
|
||||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||||
|
|
@ -332,7 +333,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
|
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 和 MiniMax M2.7,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
|
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
|
||||||
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",
|
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.title": "OpenCode Go | 低成本全民編碼模型",
|
"go.title": "OpenCode Go | 低成本全民編碼模型",
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go 首月 $5,之後 $10/月,提供對 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小時充裕請求額度。",
|
"Go 首月 $5,之後 $10/月,提供對 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 和 MiniMax M2.7 的 5 小時充裕請求額度。",
|
||||||
"go.hero.title": "低成本全民編碼模型",
|
"go.hero.title": "低成本全民編碼模型",
|
||||||
"go.hero.body":
|
"go.hero.body":
|
||||||
"Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。",
|
"Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。",
|
||||||
|
|
@ -288,7 +288,7 @@ export const dict = {
|
||||||
"go.problem.item1": "低成本訂閱定價",
|
"go.problem.item1": "低成本訂閱定價",
|
||||||
"go.problem.item2": "寬裕的限額與穩定存取",
|
"go.problem.item2": "寬裕的限額與穩定存取",
|
||||||
"go.problem.item3": "專為盡可能多的程式設計師打造",
|
"go.problem.item3": "專為盡可能多的程式設計師打造",
|
||||||
"go.problem.item4": "包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7",
|
"go.problem.item4": "包含 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 與 MiniMax M2.7",
|
||||||
"go.how.title": "Go 如何運作",
|
"go.how.title": "Go 如何運作",
|
||||||
"go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
|
"go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
|
||||||
"go.how.step1.title": "建立帳號",
|
"go.how.step1.title": "建立帳號",
|
||||||
|
|
@ -307,10 +307,11 @@ export const dict = {
|
||||||
"go.faq.q1": "什麼是 OpenCode Go?",
|
"go.faq.q1": "什麼是 OpenCode Go?",
|
||||||
"go.faq.a1": "Go 是一個低成本訂閱方案,讓你穩定存取強大的開源模型以進行代理編碼。",
|
"go.faq.a1": "Go 是一個低成本訂閱方案,讓你穩定存取強大的開源模型以進行代理編碼。",
|
||||||
"go.faq.q2": "Go 包含哪些模型?",
|
"go.faq.q2": "Go 包含哪些模型?",
|
||||||
"go.faq.a2": "Go 包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並提供寬裕的限額與穩定存取。",
|
"go.faq.a2":
|
||||||
|
"Go 包含 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 與 MiniMax M2.7,並提供寬裕的限額與穩定存取。",
|
||||||
"go.faq.q3": "Go 與 Zen 一樣嗎?",
|
"go.faq.q3": "Go 與 Zen 一樣嗎?",
|
||||||
"go.faq.a3":
|
"go.faq.a3":
|
||||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等開源模型。",
|
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 和 MiniMax M2.7 等開源模型。",
|
||||||
"go.faq.q4": "Go 費用是多少?",
|
"go.faq.q4": "Go 費用是多少?",
|
||||||
"go.faq.a4.p1.beforePricing": "Go 費用為",
|
"go.faq.a4.p1.beforePricing": "Go 費用為",
|
||||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||||
|
|
@ -332,7 +333,7 @@ export const dict = {
|
||||||
|
|
||||||
"go.faq.q9": "免費模型與 Go 有什麼區別?",
|
"go.faq.q9": "免費模型與 Go 有什麼區別?",
|
||||||
"go.faq.a9":
|
"go.faq.a9":
|
||||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 與 MiniMax M2.7,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||||
|
|
||||||
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
|
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
|
||||||
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",
|
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",
|
||||||
|
|
|
||||||
|
|
@ -564,7 +564,7 @@ body {
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 220px;
|
height: 260px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { Footer } from "~/component/footer"
|
||||||
import { Header } from "~/component/header"
|
import { Header } from "~/component/header"
|
||||||
import { config } from "~/config"
|
import { config } from "~/config"
|
||||||
import { getLastSeenWorkspaceID } from "../workspace/common"
|
import { getLastSeenWorkspaceID } from "../workspace/common"
|
||||||
import { IconMiniMax, IconZai } from "~/component/icon"
|
import { IconMiniMax, IconMiMo, IconZai } from "~/component/icon"
|
||||||
import { useI18n } from "~/context/i18n"
|
import { useI18n } from "~/context/i18n"
|
||||||
import { useLanguage } from "~/context/language"
|
import { useLanguage } from "~/context/language"
|
||||||
import { LocaleLinks } from "~/component/locale-links"
|
import { LocaleLinks } from "~/component/locale-links"
|
||||||
|
|
@ -47,12 +47,14 @@ function LimitsGraph(props: { href: string }) {
|
||||||
const models = [
|
const models = [
|
||||||
{ id: "glm", name: "GLM-5", req: 1150, d: "120ms" },
|
{ id: "glm", name: "GLM-5", req: 1150, d: "120ms" },
|
||||||
{ id: "kimi", name: "Kimi K2.5", req: 1850, d: "240ms" },
|
{ id: "kimi", name: "Kimi K2.5", req: 1850, d: "240ms" },
|
||||||
|
{ id: "mimo-v2-pro", name: "MiMo-V2-Pro", req: 1290, d: "150ms" },
|
||||||
|
{ id: "mimo-v2-omni", name: "MiMo-V2-Omni", req: 2150, d: "270ms" },
|
||||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 14000, d: "330ms" },
|
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 14000, d: "330ms" },
|
||||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", req: 20000, d: "360ms" },
|
{ id: "minimax-m2.5", name: "MiniMax M2.5", req: 20000, d: "360ms" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const w = 720
|
const w = 720
|
||||||
const h = 220
|
const h = 260
|
||||||
const left = 40
|
const left = 40
|
||||||
const right = 60
|
const right = 60
|
||||||
const top = 18
|
const top = 18
|
||||||
|
|
@ -298,6 +300,9 @@ export default function Home() {
|
||||||
<div>
|
<div>
|
||||||
<IconZai width="24" height="24" />
|
<IconZai width="24" height="24" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<IconMiMo width="24" height="24" />
|
||||||
|
</div>
|
||||||
{/*
|
{/*
|
||||||
<div>
|
<div>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "^10",
|
"electron-store": "^10",
|
||||||
"electron-updater": "^6",
|
"electron-updater": "^6",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import type { Event } from "electron"
|
||||||
import { app, BrowserWindow, dialog } from "electron"
|
import { app, BrowserWindow, dialog } from "electron"
|
||||||
import pkg from "electron-updater"
|
import pkg from "electron-updater"
|
||||||
|
|
||||||
|
import contextMenu from "electron-context-menu"
|
||||||
|
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||||
|
|
||||||
const APP_NAMES: Record<string, string> = {
|
const APP_NAMES: Record<string, string> = {
|
||||||
dev: "OpenCode Dev",
|
dev: "OpenCode Dev",
|
||||||
beta: "OpenCode Beta",
|
beta: "OpenCode Beta",
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.14.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
"@ai-sdk/amazon-bedrock": "4.0.83",
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
|
|
|
||||||
|
|
@ -214,17 +214,17 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||||
- [x] `SessionProcessor` — `session/processor.ts`
|
- [x] `SessionProcessor` — `session/processor.ts`
|
||||||
- [x] `SessionPrompt` — `session/prompt.ts`
|
- [x] `SessionPrompt` — `session/prompt.ts`
|
||||||
- [x] `SessionCompaction` — `session/compaction.ts`
|
- [x] `SessionCompaction` — `session/compaction.ts`
|
||||||
|
- [x] `SessionSummary` — `session/summary.ts`
|
||||||
|
- [x] `SessionRevert` — `session/revert.ts`
|
||||||
|
- [x] `Instruction` — `session/instruction.ts`
|
||||||
- [x] `Provider` — `provider/provider.ts`
|
- [x] `Provider` — `provider/provider.ts`
|
||||||
|
- [x] `Storage` — `storage/storage.ts`
|
||||||
|
|
||||||
Still open:
|
Still open:
|
||||||
|
|
||||||
- [ ] `SessionSummary` — `session/summary.ts`
|
|
||||||
- [ ] `SessionTodo` — `session/todo.ts`
|
- [ ] `SessionTodo` — `session/todo.ts`
|
||||||
- [ ] `SessionRevert` — `session/revert.ts`
|
|
||||||
- [ ] `Instruction` — `session/instruction.ts`
|
|
||||||
- [ ] `ShareNext` — `share/share-next.ts`
|
- [ ] `ShareNext` — `share/share-next.ts`
|
||||||
- [ ] `SyncEvent` — `sync/index.ts`
|
- [ ] `SyncEvent` — `sync/index.ts`
|
||||||
- [ ] `Storage` — `storage/storage.ts`
|
|
||||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||||
|
|
||||||
## Tool interface → Effect
|
## Tool interface → Effect
|
||||||
|
|
@ -238,6 +238,7 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
|
||||||
Individual tools, ordered by value:
|
Individual tools, ordered by value:
|
||||||
|
|
||||||
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
||||||
|
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
|
||||||
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
||||||
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
||||||
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
||||||
|
|
@ -247,40 +248,42 @@ Individual tools, ordered by value:
|
||||||
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
||||||
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
||||||
- [ ] `task.ts` — MEDIUM: task state management
|
- [ ] `task.ts` — MEDIUM: task state management
|
||||||
|
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
|
||||||
|
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
|
||||||
- [ ] `glob.ts` — LOW: simple async generator
|
- [ ] `glob.ts` — LOW: simple async generator
|
||||||
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
||||||
|
- [ ] `question.ts` — LOW: prompt wrapper
|
||||||
- [ ] `skill.ts` — LOW: skill tool adapter
|
- [ ] `skill.ts` — LOW: skill tool adapter
|
||||||
|
- [ ] `todo.ts` — LOW: todo persistence wrapper
|
||||||
|
- [ ] `invalid.ts` — LOW: invalid-tool fallback
|
||||||
- [ ] `plan.ts` — LOW: plan file operations
|
- [ ] `plan.ts` — LOW: plan file operations
|
||||||
|
|
||||||
## Effect service adoption in already-migrated code
|
## Effect service adoption in already-migrated code
|
||||||
|
|
||||||
Some services are effectified but still use raw `Filesystem.*` or `Process.spawn` instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
|
Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` in their implementation or helper modules. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
|
||||||
|
|
||||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||||
|
|
||||||
- [ ] `file/index.ts` — 11 calls (the File service itself)
|
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
|
||||||
- [ ] `config/config.ts` — 7 calls
|
- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()`
|
||||||
- [ ] `auth/index.ts` — 3 calls
|
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
|
||||||
- [ ] `skill/index.ts` — 3 calls
|
|
||||||
- [ ] `file/time.ts` — 1 call
|
|
||||||
|
|
||||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||||
|
|
||||||
- [ ] `format/index.ts` — 1 call
|
- [ ] `format/formatter.ts` — 2 remaining `Process.spawn()` checks (`air`, `uv`)
|
||||||
|
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
||||||
|
|
||||||
## Filesystem consolidation
|
## Filesystem consolidation
|
||||||
|
|
||||||
`util/filesystem.ts` (raw fs wrapper) is used by **64 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) exists but only has **8 consumers**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
||||||
|
|
||||||
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
||||||
|
|
||||||
Current raw fs users that will convert during tool migration:
|
Current raw fs users that will convert during tool migration:
|
||||||
|
|
||||||
- `tool/read.ts` — fs.createReadStream, readline
|
- `tool/read.ts` — fs.createReadStream, readline
|
||||||
- `tool/apply_patch.ts` — fs/promises
|
- `tool/apply_patch.ts` — fs/promises
|
||||||
- `tool/bash.ts` — fs/promises
|
|
||||||
- `file/ripgrep.ts` — fs/promises
|
- `file/ripgrep.ts` — fs/promises
|
||||||
- `storage/storage.ts` — fs/promises
|
|
||||||
- `patch/index.ts` — fs, fs/promises
|
- `patch/index.ts` — fs, fs/promises
|
||||||
|
|
||||||
## Primitives & utilities
|
## Primitives & utilities
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,19 @@ export namespace ACP {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (part.type !== "text" && part.type !== "file") return
|
||||||
|
const msg = await this.sdk.session
|
||||||
|
.message(
|
||||||
|
{ sessionID: part.sessionID, messageID: part.messageID, directory: session.cwd },
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch((err) => {
|
||||||
|
log.error("failed to fetch message for user chunk", { error: err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!msg || msg.info.role !== "user") return
|
||||||
|
await this.processMessage({ info: msg.info, parts: [part] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -484,6 +497,7 @@ export namespace ACP {
|
||||||
sessionId,
|
sessionId,
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: "agent_message_chunk",
|
sessionUpdate: "agent_message_chunk",
|
||||||
|
messageId: props.messageID,
|
||||||
content: {
|
content: {
|
||||||
type: "text",
|
type: "text",
|
||||||
text: props.delta,
|
text: props.delta,
|
||||||
|
|
@ -502,6 +516,7 @@ export namespace ACP {
|
||||||
sessionId,
|
sessionId,
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: "agent_thought_chunk",
|
sessionUpdate: "agent_thought_chunk",
|
||||||
|
messageId: props.messageID,
|
||||||
content: {
|
content: {
|
||||||
type: "text",
|
type: "text",
|
||||||
text: props.delta,
|
text: props.delta,
|
||||||
|
|
@ -666,7 +681,7 @@ export namespace ACP {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||||
try {
|
try {
|
||||||
const cursor = params.cursor ? Number(params.cursor) : undefined
|
const cursor = params.cursor ? Number(params.cursor) : undefined
|
||||||
const limit = 100
|
const limit = 100
|
||||||
|
|
@ -970,6 +985,7 @@ export namespace ACP {
|
||||||
sessionId,
|
sessionId,
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
|
sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
|
||||||
|
messageId: message.info.id,
|
||||||
content: {
|
content: {
|
||||||
type: "text",
|
type: "text",
|
||||||
text: part.text,
|
text: part.text,
|
||||||
|
|
@ -1001,6 +1017,7 @@ export namespace ACP {
|
||||||
sessionId,
|
sessionId,
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: messageChunk,
|
sessionUpdate: messageChunk,
|
||||||
|
messageId: message.info.id,
|
||||||
content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
|
content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -1022,6 +1039,7 @@ export namespace ACP {
|
||||||
sessionId,
|
sessionId,
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: messageChunk,
|
sessionUpdate: messageChunk,
|
||||||
|
messageId: message.info.id,
|
||||||
content: {
|
content: {
|
||||||
type: "image",
|
type: "image",
|
||||||
mimeType: effectiveMime,
|
mimeType: effectiveMime,
|
||||||
|
|
@ -1050,6 +1068,7 @@ export namespace ACP {
|
||||||
sessionId,
|
sessionId,
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: messageChunk,
|
sessionUpdate: messageChunk,
|
||||||
|
messageId: message.info.id,
|
||||||
content: { type: "resource", resource },
|
content: { type: "resource", resource },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -1066,6 +1085,7 @@ export namespace ACP {
|
||||||
sessionId,
|
sessionId,
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: "agent_thought_chunk",
|
sessionUpdate: "agent_thought_chunk",
|
||||||
|
messageId: message.info.id,
|
||||||
content: {
|
content: {
|
||||||
type: "text",
|
type: "text",
|
||||||
text: part.text,
|
text: part.text,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Log } from "../util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
|
import { Process } from "../util/process"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ModelsDev } from "../provider/models"
|
import { ModelsDev } from "../provider/models"
|
||||||
import { mergeDeep, pipe, unique } from "remeda"
|
import { mergeDeep, pipe, unique } from "remeda"
|
||||||
|
|
@ -75,6 +76,59 @@ export namespace Config {
|
||||||
|
|
||||||
const managedDir = managedConfigDir()
|
const managedDir = managedConfigDir()
|
||||||
|
|
||||||
|
const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
|
||||||
|
|
||||||
|
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config
|
||||||
|
const PLIST_META = new Set([
|
||||||
|
"PayloadDisplayName",
|
||||||
|
"PayloadIdentifier",
|
||||||
|
"PayloadType",
|
||||||
|
"PayloadUUID",
|
||||||
|
"PayloadVersion",
|
||||||
|
"_manualProfile",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw JSON (from plutil conversion of a managed plist) into OpenCode config.
|
||||||
|
* Strips MDM metadata keys before parsing through the config schema.
|
||||||
|
* Pure function — no OS interaction, safe to unit test directly.
|
||||||
|
*/
|
||||||
|
export function parseManagedPlist(json: string, source: string): Info {
|
||||||
|
const raw = JSON.parse(json)
|
||||||
|
for (const key of Object.keys(raw)) {
|
||||||
|
if (PLIST_META.has(key)) delete raw[key]
|
||||||
|
}
|
||||||
|
return parseConfig(JSON.stringify(raw), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read macOS managed preferences deployed via .mobileconfig / MDM (Jamf, Kandji, etc).
|
||||||
|
* MDM-installed profiles write to /Library/Managed Preferences/ which is only writable by root.
|
||||||
|
* User-scoped plists are checked first, then machine-scoped.
|
||||||
|
*/
|
||||||
|
async function readManagedPreferences(): Promise<Info> {
|
||||||
|
if (process.platform !== "darwin") return {}
|
||||||
|
|
||||||
|
const domain = MANAGED_PLIST_DOMAIN
|
||||||
|
const user = os.userInfo().username
|
||||||
|
const paths = [
|
||||||
|
path.join("/Library/Managed Preferences", user, `${domain}.plist`),
|
||||||
|
path.join("/Library/Managed Preferences", `${domain}.plist`),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const plist of paths) {
|
||||||
|
if (!existsSync(plist)) continue
|
||||||
|
log.info("reading macOS managed preferences", { path: plist })
|
||||||
|
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to convert managed preferences plist", { path: plist })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`)
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom merge function that concatenates array fields instead of replacing them
|
// Custom merge function that concatenates array fields instead of replacing them
|
||||||
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||||
const merged = mergeDeep(target, source)
|
const merged = mergeDeep(target, source)
|
||||||
|
|
@ -1356,6 +1410,9 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
|
||||||
|
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
|
||||||
|
|
||||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||||
result.agent = mergeDeep(result.agent ?? {}, {
|
result.agent = mergeDeep(result.agent ?? {}, {
|
||||||
[name]: {
|
[name]: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { text } from "node:stream/consumers"
|
import { text } from "node:stream/consumers"
|
||||||
|
import { Npm } from "@/npm"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
|
|
@ -7,33 +8,33 @@ import { Flag } from "@/flag/flag"
|
||||||
|
|
||||||
export interface Info {
|
export interface Info {
|
||||||
name: string
|
name: string
|
||||||
command: string[]
|
|
||||||
environment?: Record<string, string>
|
environment?: Record<string, string>
|
||||||
extensions: string[]
|
extensions: string[]
|
||||||
enabled(): Promise<boolean>
|
enabled(): Promise<string[] | false>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gofmt: Info = {
|
export const gofmt: Info = {
|
||||||
name: "gofmt",
|
name: "gofmt",
|
||||||
command: ["gofmt", "-w", "$FILE"],
|
|
||||||
extensions: [".go"],
|
extensions: [".go"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("gofmt") !== null
|
const match = which("gofmt")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "-w", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mix: Info = {
|
export const mix: Info = {
|
||||||
name: "mix",
|
name: "mix",
|
||||||
command: ["mix", "format", "$FILE"],
|
|
||||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("mix") !== null
|
const match = which("mix")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "format", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const prettier: Info = {
|
export const prettier: Info = {
|
||||||
name: "prettier",
|
name: "prettier",
|
||||||
command: ["bun", "x", "prettier", "--write", "$FILE"],
|
|
||||||
environment: {
|
environment: {
|
||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
},
|
},
|
||||||
|
|
@ -72,8 +73,10 @@ export const prettier: Info = {
|
||||||
dependencies?: Record<string, string>
|
dependencies?: Record<string, string>
|
||||||
devDependencies?: Record<string, string>
|
devDependencies?: Record<string, string>
|
||||||
}>(item)
|
}>(item)
|
||||||
if (json.dependencies?.prettier) return true
|
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
|
||||||
if (json.devDependencies?.prettier) return true
|
const bin = await Npm.which("prettier")
|
||||||
|
if (bin) return [bin, "--write", "$FILE"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
|
@ -81,7 +84,6 @@ export const prettier: Info = {
|
||||||
|
|
||||||
export const oxfmt: Info = {
|
export const oxfmt: Info = {
|
||||||
name: "oxfmt",
|
name: "oxfmt",
|
||||||
command: ["bun", "x", "oxfmt", "$FILE"],
|
|
||||||
environment: {
|
environment: {
|
||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
},
|
},
|
||||||
|
|
@ -94,8 +96,10 @@ export const oxfmt: Info = {
|
||||||
dependencies?: Record<string, string>
|
dependencies?: Record<string, string>
|
||||||
devDependencies?: Record<string, string>
|
devDependencies?: Record<string, string>
|
||||||
}>(item)
|
}>(item)
|
||||||
if (json.dependencies?.oxfmt) return true
|
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
|
||||||
if (json.devDependencies?.oxfmt) return true
|
const bin = await Npm.which("oxfmt")
|
||||||
|
if (bin) return [bin, "$FILE"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
|
@ -103,7 +107,6 @@ export const oxfmt: Info = {
|
||||||
|
|
||||||
export const biome: Info = {
|
export const biome: Info = {
|
||||||
name: "biome",
|
name: "biome",
|
||||||
command: ["bun", "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
|
||||||
environment: {
|
environment: {
|
||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
},
|
},
|
||||||
|
|
@ -140,7 +143,8 @@ export const biome: Info = {
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||||
if (found.length > 0) {
|
if (found.length > 0) {
|
||||||
return true
|
const bin = await Npm.which("@biomejs/biome")
|
||||||
|
if (bin) return [bin, "format", "--write", "$FILE"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -149,35 +153,39 @@ export const biome: Info = {
|
||||||
|
|
||||||
export const zig: Info = {
|
export const zig: Info = {
|
||||||
name: "zig",
|
name: "zig",
|
||||||
command: ["zig", "fmt", "$FILE"],
|
|
||||||
extensions: [".zig", ".zon"],
|
extensions: [".zig", ".zon"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("zig") !== null
|
const match = which("zig")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "fmt", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clang: Info = {
|
export const clang: Info = {
|
||||||
name: "clang-format",
|
name: "clang-format",
|
||||||
command: ["clang-format", "-i", "$FILE"],
|
|
||||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
|
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
|
||||||
return items.length > 0
|
if (items.length > 0) {
|
||||||
|
const match = which("clang-format")
|
||||||
|
if (match) return [match, "-i", "$FILE"]
|
||||||
|
}
|
||||||
|
return false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ktlint: Info = {
|
export const ktlint: Info = {
|
||||||
name: "ktlint",
|
name: "ktlint",
|
||||||
command: ["ktlint", "-F", "$FILE"],
|
|
||||||
extensions: [".kt", ".kts"],
|
extensions: [".kt", ".kts"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("ktlint") !== null
|
const match = which("ktlint")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "-F", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ruff: Info = {
|
export const ruff: Info = {
|
||||||
name: "ruff",
|
name: "ruff",
|
||||||
command: ["ruff", "format", "$FILE"],
|
|
||||||
extensions: [".py", ".pyi"],
|
extensions: [".py", ".pyi"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
if (!which("ruff")) return false
|
if (!which("ruff")) return false
|
||||||
|
|
@ -187,9 +195,9 @@ export const ruff: Info = {
|
||||||
if (found.length > 0) {
|
if (found.length > 0) {
|
||||||
if (config === "pyproject.toml") {
|
if (config === "pyproject.toml") {
|
||||||
const content = await Filesystem.readText(found[0])
|
const content = await Filesystem.readText(found[0])
|
||||||
if (content.includes("[tool.ruff]")) return true
|
if (content.includes("[tool.ruff]")) return ["ruff", "format", "$FILE"]
|
||||||
} else {
|
} else {
|
||||||
return true
|
return ["ruff", "format", "$FILE"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +206,7 @@ export const ruff: Info = {
|
||||||
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
|
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
|
||||||
if (found.length > 0) {
|
if (found.length > 0) {
|
||||||
const content = await Filesystem.readText(found[0])
|
const content = await Filesystem.readText(found[0])
|
||||||
if (content.includes("ruff")) return true
|
if (content.includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -207,7 +215,6 @@ export const ruff: Info = {
|
||||||
|
|
||||||
export const rlang: Info = {
|
export const rlang: Info = {
|
||||||
name: "air",
|
name: "air",
|
||||||
command: ["air", "format", "$FILE"],
|
|
||||||
extensions: [".R"],
|
extensions: [".R"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
const airPath = which("air")
|
const airPath = which("air")
|
||||||
|
|
@ -226,23 +233,23 @@ export const rlang: Info = {
|
||||||
const firstLine = output.split("\n")[0]
|
const firstLine = output.split("\n")[0]
|
||||||
const hasR = firstLine.includes("R language")
|
const hasR = firstLine.includes("R language")
|
||||||
const hasFormatter = firstLine.includes("formatter")
|
const hasFormatter = firstLine.includes("formatter")
|
||||||
return hasR && hasFormatter
|
if (hasR && hasFormatter) return ["air", "format", "$FILE"]
|
||||||
} catch (error) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uvformat: Info = {
|
export const uvformat: Info = {
|
||||||
name: "uv",
|
name: "uv",
|
||||||
command: ["uv", "format", "--", "$FILE"],
|
|
||||||
extensions: [".py", ".pyi"],
|
extensions: [".py", ".pyi"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
if (await ruff.enabled()) return false
|
if (await ruff.enabled()) return false
|
||||||
if (which("uv") !== null) {
|
if (which("uv") !== null) {
|
||||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||||
const code = await proc.exited
|
const code = await proc.exited
|
||||||
return code === 0
|
if (code === 0) return ["uv", "format", "--", "$FILE"]
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
|
@ -250,108 +257,117 @@ export const uvformat: Info = {
|
||||||
|
|
||||||
export const rubocop: Info = {
|
export const rubocop: Info = {
|
||||||
name: "rubocop",
|
name: "rubocop",
|
||||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
|
||||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("rubocop") !== null
|
const match = which("rubocop")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "--autocorrect", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const standardrb: Info = {
|
export const standardrb: Info = {
|
||||||
name: "standardrb",
|
name: "standardrb",
|
||||||
command: ["standardrb", "--fix", "$FILE"],
|
|
||||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("standardrb") !== null
|
const match = which("standardrb")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "--fix", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const htmlbeautifier: Info = {
|
export const htmlbeautifier: Info = {
|
||||||
name: "htmlbeautifier",
|
name: "htmlbeautifier",
|
||||||
command: ["htmlbeautifier", "$FILE"],
|
|
||||||
extensions: [".erb", ".html.erb"],
|
extensions: [".erb", ".html.erb"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("htmlbeautifier") !== null
|
const match = which("htmlbeautifier")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dart: Info = {
|
export const dart: Info = {
|
||||||
name: "dart",
|
name: "dart",
|
||||||
command: ["dart", "format", "$FILE"],
|
|
||||||
extensions: [".dart"],
|
extensions: [".dart"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("dart") !== null
|
const match = which("dart")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "format", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ocamlformat: Info = {
|
export const ocamlformat: Info = {
|
||||||
name: "ocamlformat",
|
name: "ocamlformat",
|
||||||
command: ["ocamlformat", "-i", "$FILE"],
|
|
||||||
extensions: [".ml", ".mli"],
|
extensions: [".ml", ".mli"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
if (!which("ocamlformat")) return false
|
if (!which("ocamlformat")) return false
|
||||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||||
return items.length > 0
|
if (items.length > 0) return ["ocamlformat", "-i", "$FILE"]
|
||||||
|
return false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const terraform: Info = {
|
export const terraform: Info = {
|
||||||
name: "terraform",
|
name: "terraform",
|
||||||
command: ["terraform", "fmt", "$FILE"],
|
|
||||||
extensions: [".tf", ".tfvars"],
|
extensions: [".tf", ".tfvars"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("terraform") !== null
|
const match = which("terraform")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "fmt", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const latexindent: Info = {
|
export const latexindent: Info = {
|
||||||
name: "latexindent",
|
name: "latexindent",
|
||||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
|
||||||
extensions: [".tex"],
|
extensions: [".tex"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("latexindent") !== null
|
const match = which("latexindent")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "-w", "-s", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gleam: Info = {
|
export const gleam: Info = {
|
||||||
name: "gleam",
|
name: "gleam",
|
||||||
command: ["gleam", "format", "$FILE"],
|
|
||||||
extensions: [".gleam"],
|
extensions: [".gleam"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("gleam") !== null
|
const match = which("gleam")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "format", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shfmt: Info = {
|
export const shfmt: Info = {
|
||||||
name: "shfmt",
|
name: "shfmt",
|
||||||
command: ["shfmt", "-w", "$FILE"],
|
|
||||||
extensions: [".sh", ".bash"],
|
extensions: [".sh", ".bash"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("shfmt") !== null
|
const match = which("shfmt")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "-w", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const nixfmt: Info = {
|
export const nixfmt: Info = {
|
||||||
name: "nixfmt",
|
name: "nixfmt",
|
||||||
command: ["nixfmt", "$FILE"],
|
|
||||||
extensions: [".nix"],
|
extensions: [".nix"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("nixfmt") !== null
|
const match = which("nixfmt")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rustfmt: Info = {
|
export const rustfmt: Info = {
|
||||||
name: "rustfmt",
|
name: "rustfmt",
|
||||||
command: ["rustfmt", "$FILE"],
|
|
||||||
extensions: [".rs"],
|
extensions: [".rs"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("rustfmt") !== null
|
const match = which("rustfmt")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pint: Info = {
|
export const pint: Info = {
|
||||||
name: "pint",
|
name: "pint",
|
||||||
command: ["./vendor/bin/pint", "$FILE"],
|
|
||||||
extensions: [".php"],
|
extensions: [".php"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
|
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
|
||||||
|
|
@ -360,8 +376,7 @@ export const pint: Info = {
|
||||||
require?: Record<string, string>
|
require?: Record<string, string>
|
||||||
"require-dev"?: Record<string, string>
|
"require-dev"?: Record<string, string>
|
||||||
}>(item)
|
}>(item)
|
||||||
if (json.require?.["laravel/pint"]) return true
|
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) return ["./vendor/bin/pint", "$FILE"]
|
||||||
if (json["require-dev"]?.["laravel/pint"]) return true
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
|
@ -369,27 +384,30 @@ export const pint: Info = {
|
||||||
|
|
||||||
export const ormolu: Info = {
|
export const ormolu: Info = {
|
||||||
name: "ormolu",
|
name: "ormolu",
|
||||||
command: ["ormolu", "-i", "$FILE"],
|
|
||||||
extensions: [".hs"],
|
extensions: [".hs"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("ormolu") !== null
|
const match = which("ormolu")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "-i", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cljfmt: Info = {
|
export const cljfmt: Info = {
|
||||||
name: "cljfmt",
|
name: "cljfmt",
|
||||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
|
||||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("cljfmt") !== null
|
const match = which("cljfmt")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "fix", "--quiet", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dfmt: Info = {
|
export const dfmt: Info = {
|
||||||
name: "dfmt",
|
name: "dfmt",
|
||||||
command: ["dfmt", "-i", "$FILE"],
|
|
||||||
extensions: [".d"],
|
extensions: [".d"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
return which("dfmt") !== null
|
const match = which("dfmt")
|
||||||
|
if (!match) return false
|
||||||
|
return [match, "-i", "$FILE"]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export namespace Format {
|
||||||
|
|
||||||
const state = yield* InstanceState.make(
|
const state = yield* InstanceState.make(
|
||||||
Effect.fn("Format.state")(function* (_ctx) {
|
Effect.fn("Format.state")(function* (_ctx) {
|
||||||
const enabled: Record<string, boolean> = {}
|
const commands: Record<string, string[] | false> = {}
|
||||||
const formatters: Record<string, Formatter.Info> = {}
|
const formatters: Record<string, Formatter.Info> = {}
|
||||||
|
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
|
|
@ -56,30 +56,32 @@ export namespace Format {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const info = mergeDeep(formatters[name] ?? {}, {
|
const info = mergeDeep(formatters[name] ?? {}, {
|
||||||
command: [],
|
|
||||||
extensions: [],
|
extensions: [],
|
||||||
...item,
|
...item,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (info.command.length === 0) continue
|
|
||||||
|
|
||||||
formatters[name] = {
|
formatters[name] = {
|
||||||
...info,
|
...info,
|
||||||
name,
|
name,
|
||||||
enabled: async () => true,
|
enabled: async () => info.command ?? false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.info("all formatters are disabled")
|
log.info("all formatters are disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isEnabled(item: Formatter.Info) {
|
async function getCommand(item: Formatter.Info) {
|
||||||
let status = enabled[item.name]
|
let cmd = commands[item.name]
|
||||||
if (status === undefined) {
|
if (cmd === false || cmd === undefined) {
|
||||||
status = await item.enabled()
|
cmd = await item.enabled()
|
||||||
enabled[item.name] = status
|
commands[item.name] = cmd
|
||||||
}
|
}
|
||||||
return status
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEnabled(item: Formatter.Info) {
|
||||||
|
const cmd = await getCommand(item)
|
||||||
|
return cmd !== false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFormatter(ext: string) {
|
async function getFormatter(ext: string) {
|
||||||
|
|
@ -87,17 +89,17 @@ export namespace Format {
|
||||||
const checks = await Promise.all(
|
const checks = await Promise.all(
|
||||||
matching.map(async (item) => {
|
matching.map(async (item) => {
|
||||||
log.info("checking", { name: item.name, ext })
|
log.info("checking", { name: item.name, ext })
|
||||||
const on = await isEnabled(item)
|
const cmd = await getCommand(item)
|
||||||
if (on) {
|
if (cmd) {
|
||||||
log.info("enabled", { name: item.name, ext })
|
log.info("enabled", { name: item.name, ext })
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
enabled: on,
|
cmd,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFile(filepath: string) {
|
function formatFile(filepath: string) {
|
||||||
|
|
@ -105,13 +107,14 @@ export namespace Format {
|
||||||
log.info("formatting", { file: filepath })
|
log.info("formatting", { file: filepath })
|
||||||
const ext = path.extname(filepath)
|
const ext = path.extname(filepath)
|
||||||
|
|
||||||
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) {
|
||||||
log.info("running", { command: item.command })
|
if (cmd === false) continue
|
||||||
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
|
log.info("running", { command: cmd })
|
||||||
|
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
|
||||||
const dir = yield* InstanceState.directory
|
const dir = yield* InstanceState.directory
|
||||||
const code = yield* spawner
|
const code = yield* spawner
|
||||||
.spawn(
|
.spawn(
|
||||||
ChildProcess.make(cmd[0]!, cmd.slice(1), {
|
ChildProcess.make(replaced[0]!, replaced.slice(1), {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
env: item.environment,
|
env: item.environment,
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
|
|
@ -124,7 +127,7 @@ export namespace Format {
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
log.error("failed to format file", {
|
log.error("failed to format file", {
|
||||||
error: "spawn failed",
|
error: "spawn failed",
|
||||||
command: item.command,
|
command: cmd,
|
||||||
...item.environment,
|
...item.environment,
|
||||||
file: filepath,
|
file: filepath,
|
||||||
})
|
})
|
||||||
|
|
@ -134,7 +137,7 @@ export namespace Format {
|
||||||
)
|
)
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
command: item.command,
|
command: cmd,
|
||||||
...item.environment,
|
...item.environment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export namespace CopilotModels {
|
||||||
): Promise<Record<string, Model>> {
|
): Promise<Record<string, Model>> {
|
||||||
const data = await fetch(`${baseURL}/models`, {
|
const data = await fetch(`${baseURL}/models`, {
|
||||||
headers,
|
headers,
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to fetch models: ${res.status}`)
|
throw new Error(`Failed to fetch models: ${res.status}`)
|
||||||
|
|
|
||||||
60410
packages/opencode/src/provider/models-snapshot.ts
Normal file
60410
packages/opencode/src/provider/models-snapshot.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -114,6 +114,12 @@ export namespace Provider {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function e2eURL() {
|
||||||
|
const url = Env.get("OPENCODE_E2E_LLM_URL")
|
||||||
|
if (typeof url !== "string" || url === "") return
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
type BundledSDK = {
|
type BundledSDK = {
|
||||||
languageModel(modelId: string): LanguageModelV3
|
languageModel(modelId: string): LanguageModelV3
|
||||||
}
|
}
|
||||||
|
|
@ -1450,6 +1456,17 @@ export namespace Provider {
|
||||||
if (s.models.has(key)) return s.models.get(key)!
|
if (s.models.has(key)) return s.models.get(key)!
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
|
const url = e2eURL()
|
||||||
|
if (url) {
|
||||||
|
const language = createOpenAICompatible({
|
||||||
|
name: model.providerID,
|
||||||
|
apiKey: "test-key",
|
||||||
|
baseURL: url,
|
||||||
|
}).chatModel(model.api.id)
|
||||||
|
s.models.set(key, language)
|
||||||
|
return language
|
||||||
|
}
|
||||||
|
|
||||||
const provider = s.providers[model.providerID]
|
const provider = s.providers[model.providerID]
|
||||||
const sdk = await resolveSDK(model, s)
|
const sdk = await resolveSDK(model, s)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -756,7 +756,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||||
}
|
}
|
||||||
const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID))
|
const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID))
|
||||||
const userMsg: MessageV2.User = {
|
const userMsg: MessageV2.User = {
|
||||||
id: MessageID.ascending(),
|
id: input.messageID ?? MessageID.ascending(),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -1827,6 +1827,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||||
|
|
||||||
export const ShellInput = z.object({
|
export const ShellInput = z.object({
|
||||||
sessionID: SessionID.zod,
|
sessionID: SessionID.zod,
|
||||||
|
messageID: MessageID.zod.optional(),
|
||||||
agent: z.string(),
|
agent: z.string(),
|
||||||
model: z
|
model: z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { pathToFileURL } from "url"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Env } from "../env"
|
||||||
|
|
||||||
export namespace ToolRegistry {
|
export namespace ToolRegistry {
|
||||||
const log = Log.create({ service: "tool.registry" })
|
const log = Log.create({ service: "tool.registry" })
|
||||||
|
|
@ -166,7 +167,8 @@ export namespace ToolRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
const usePatch =
|
const usePatch =
|
||||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
||||||
|
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
|
||||||
if (tool.id === "apply_patch") return usePatch
|
if (tool.id === "apply_patch") return usePatch
|
||||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ describe("acp.agent interface compliance", () => {
|
||||||
"setSessionMode",
|
"setSessionMode",
|
||||||
"authenticate",
|
"authenticate",
|
||||||
// Unstable - SDK checks these with unstable_ prefix
|
// Unstable - SDK checks these with unstable_ prefix
|
||||||
"unstable_listSessions",
|
"listSessions",
|
||||||
"unstable_forkSession",
|
"unstable_forkSession",
|
||||||
"unstable_resumeSession",
|
"unstable_resumeSession",
|
||||||
"unstable_setSessionModel",
|
"unstable_setSessionModel",
|
||||||
|
|
|
||||||
|
|
@ -2265,3 +2265,84 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// parseManagedPlist unit tests — pure function, no OS interaction
|
||||||
|
|
||||||
|
test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({
|
||||||
|
PayloadDisplayName: "OpenCode Managed",
|
||||||
|
PayloadIdentifier: "ai.opencode.managed.test",
|
||||||
|
PayloadType: "ai.opencode.managed",
|
||||||
|
PayloadUUID: "AAAA-BBBB-CCCC",
|
||||||
|
PayloadVersion: 1,
|
||||||
|
_manualProfile: true,
|
||||||
|
share: "disabled",
|
||||||
|
model: "mdm/model",
|
||||||
|
}),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.share).toBe("disabled")
|
||||||
|
expect(config.model).toBe("mdm/model")
|
||||||
|
// MDM keys must not leak into the parsed config
|
||||||
|
expect((config as any).PayloadUUID).toBeUndefined()
|
||||||
|
expect((config as any).PayloadType).toBeUndefined()
|
||||||
|
expect((config as any)._manualProfile).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseManagedPlist parses server settings", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
server: { hostname: "127.0.0.1", mdns: false },
|
||||||
|
autoupdate: true,
|
||||||
|
}),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.server?.hostname).toBe("127.0.0.1")
|
||||||
|
expect(config.server?.mdns).toBe(false)
|
||||||
|
expect(config.autoupdate).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseManagedPlist parses permission rules", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
permission: {
|
||||||
|
"*": "ask",
|
||||||
|
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
||||||
|
grep: "allow",
|
||||||
|
glob: "allow",
|
||||||
|
webfetch: "ask",
|
||||||
|
"~/.ssh/*": "deny",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.permission?.["*"]).toBe("ask")
|
||||||
|
expect(config.permission?.grep).toBe("allow")
|
||||||
|
expect(config.permission?.webfetch).toBe("ask")
|
||||||
|
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
|
||||||
|
const bash = config.permission?.bash as Record<string, string>
|
||||||
|
expect(bash?.["rm -rf *"]).toBe("deny")
|
||||||
|
expect(bash?.["curl *"]).toBe("deny")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseManagedPlist parses enabled_providers", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
enabled_providers: ["anthropic", "google"],
|
||||||
|
}),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.enabled_providers).toEqual(["anthropic", "google"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseManagedPlist handles empty config", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.$schema).toBe("https://opencode.ai/config.json")
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,17 @@ describe("cross-spawn spawner", () => {
|
||||||
fx.effect(
|
fx.effect(
|
||||||
"captures both stdout and stderr",
|
"captures both stdout and stderr",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
|
const handle = yield* js(
|
||||||
|
[
|
||||||
|
"let pending = 2",
|
||||||
|
"const done = () => {",
|
||||||
|
" pending -= 1",
|
||||||
|
" if (pending === 0) setTimeout(() => process.exit(0), 0)",
|
||||||
|
"}",
|
||||||
|
'process.stdout.write("stdout\\n", done)',
|
||||||
|
'process.stderr.write("stderr\\n", done)',
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
|
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
|
||||||
expect(stdout).toBe("stdout")
|
expect(stdout).toBe("stdout")
|
||||||
expect(stderr).toBe("stderr")
|
expect(stderr).toBe("stderr")
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,10 @@ describe("Format", () => {
|
||||||
const one = {
|
const one = {
|
||||||
extensions: Formatter.gofmt.extensions,
|
extensions: Formatter.gofmt.extensions,
|
||||||
enabled: Formatter.gofmt.enabled,
|
enabled: Formatter.gofmt.enabled,
|
||||||
command: Formatter.gofmt.command,
|
|
||||||
}
|
}
|
||||||
const two = {
|
const two = {
|
||||||
extensions: Formatter.mix.extensions,
|
extensions: Formatter.mix.extensions,
|
||||||
enabled: Formatter.mix.enabled,
|
enabled: Formatter.mix.enabled,
|
||||||
command: Formatter.mix.command,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let active = 0
|
let active = 0
|
||||||
|
|
@ -102,21 +100,19 @@ describe("Format", () => {
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
Formatter.gofmt.extensions = [".parallel"]
|
Formatter.gofmt.extensions = [".parallel"]
|
||||||
Formatter.mix.extensions = [".parallel"]
|
Formatter.mix.extensions = [".parallel"]
|
||||||
Formatter.gofmt.command = ["sh", "-c", "true"]
|
|
||||||
Formatter.mix.command = ["sh", "-c", "true"]
|
|
||||||
Formatter.gofmt.enabled = async () => {
|
Formatter.gofmt.enabled = async () => {
|
||||||
active++
|
active++
|
||||||
max = Math.max(max, active)
|
max = Math.max(max, active)
|
||||||
await Bun.sleep(20)
|
await Bun.sleep(20)
|
||||||
active--
|
active--
|
||||||
return true
|
return ["sh", "-c", "true"]
|
||||||
}
|
}
|
||||||
Formatter.mix.enabled = async () => {
|
Formatter.mix.enabled = async () => {
|
||||||
active++
|
active++
|
||||||
max = Math.max(max, active)
|
max = Math.max(max, active)
|
||||||
await Bun.sleep(20)
|
await Bun.sleep(20)
|
||||||
active--
|
active--
|
||||||
return true
|
return ["sh", "-c", "true"]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -130,10 +126,8 @@ describe("Format", () => {
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
Formatter.gofmt.extensions = one.extensions
|
Formatter.gofmt.extensions = one.extensions
|
||||||
Formatter.gofmt.enabled = one.enabled
|
Formatter.gofmt.enabled = one.enabled
|
||||||
Formatter.gofmt.command = one.command
|
|
||||||
Formatter.mix.extensions = two.extensions
|
Formatter.mix.extensions = two.extensions
|
||||||
Formatter.mix.enabled = two.enabled
|
Formatter.mix.enabled = two.enabled
|
||||||
Formatter.mix.command = two.command
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,16 @@ function responseToolArgs(id: string, text: string, seq: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function responseToolArgsDone(id: string, args: string, seq: number) {
|
||||||
|
return {
|
||||||
|
type: "response.function_call_arguments.done",
|
||||||
|
sequence_number: seq,
|
||||||
|
output_index: 0,
|
||||||
|
item_id: id,
|
||||||
|
arguments: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
|
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
|
||||||
return {
|
return {
|
||||||
type: "response.output_item.done",
|
type: "response.output_item.done",
|
||||||
|
|
@ -390,6 +400,8 @@ function responses(item: Sse, model: string) {
|
||||||
lines.push(responseReasonDone(reason, seq))
|
lines.push(responseReasonDone(reason, seq))
|
||||||
}
|
}
|
||||||
if (call && !item.hang && !item.error) {
|
if (call && !item.hang && !item.error) {
|
||||||
|
seq += 1
|
||||||
|
lines.push(responseToolArgsDone(call.item, call.args, seq))
|
||||||
seq += 1
|
seq += 1
|
||||||
lines.push(responseToolDone(call, seq))
|
lines.push(responseToolDone(call, seq))
|
||||||
}
|
}
|
||||||
|
|
@ -599,6 +611,11 @@ function isToolResultFollowUp(body: unknown): boolean {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTitleRequest(body: unknown): boolean {
|
||||||
|
if (!body || typeof body !== "object") return false
|
||||||
|
return JSON.stringify(body).includes("Generate a title for this conversation")
|
||||||
|
}
|
||||||
|
|
||||||
function requestSummary(body: unknown): string {
|
function requestSummary(body: unknown): string {
|
||||||
if (!body || typeof body !== "object") return "empty body"
|
if (!body || typeof body !== "object") return "empty body"
|
||||||
if ("messages" in body && Array.isArray(body.messages)) {
|
if ("messages" in body && Array.isArray(body.messages)) {
|
||||||
|
|
@ -623,6 +640,7 @@ namespace TestLLMServer {
|
||||||
readonly error: (status: number, body: unknown) => Effect.Effect<void>
|
readonly error: (status: number, body: unknown) => Effect.Effect<void>
|
||||||
readonly hang: Effect.Effect<void>
|
readonly hang: Effect.Effect<void>
|
||||||
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
||||||
|
readonly reset: Effect.Effect<void>
|
||||||
readonly hits: Effect.Effect<Hit[]>
|
readonly hits: Effect.Effect<Hit[]>
|
||||||
readonly calls: Effect.Effect<number>
|
readonly calls: Effect.Effect<number>
|
||||||
readonly wait: (count: number) => Effect.Effect<void>
|
readonly wait: (count: number) => Effect.Effect<void>
|
||||||
|
|
@ -671,21 +689,20 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||||
const current = hit(req.originalUrl, body)
|
const current = hit(req.originalUrl, body)
|
||||||
|
if (isTitleRequest(body)) {
|
||||||
|
hits = [...hits, current]
|
||||||
|
yield* notify()
|
||||||
|
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] }
|
||||||
|
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
||||||
|
return send(auto)
|
||||||
|
}
|
||||||
const next = pull(current)
|
const next = pull(current)
|
||||||
if (!next) {
|
if (!next) {
|
||||||
// Auto-acknowledge tool-result follow-ups so tests only need to
|
hits = [...hits, current]
|
||||||
// queue one response per tool call instead of two.
|
yield* notify()
|
||||||
if (isToolResultFollowUp(body)) {
|
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
|
||||||
hits = [...hits, current]
|
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
||||||
yield* notify()
|
return send(auto)
|
||||||
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
|
|
||||||
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
|
||||||
return send(auto)
|
|
||||||
}
|
|
||||||
misses = [...misses, current]
|
|
||||||
const summary = requestSummary(body)
|
|
||||||
console.warn(`[TestLLMServer] unmatched request: ${req.originalUrl} (${summary}, pending=${list.length})`)
|
|
||||||
return HttpServerResponse.text(`unexpected request: ${summary}`, { status: 500 })
|
|
||||||
}
|
}
|
||||||
hits = [...hits, current]
|
hits = [...hits, current]
|
||||||
yield* notify()
|
yield* notify()
|
||||||
|
|
@ -755,6 +772,12 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
|
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
|
||||||
queue(reply().wait(wait).text(value).stop().item())
|
queue(reply().wait(wait).text(value).stop().item())
|
||||||
}),
|
}),
|
||||||
|
reset: Effect.sync(() => {
|
||||||
|
hits = []
|
||||||
|
list = []
|
||||||
|
waits = []
|
||||||
|
misses = []
|
||||||
|
}),
|
||||||
hits: Effect.sync(() => [...hits]),
|
hits: Effect.sync(() => [...hits]),
|
||||||
calls: Effect.sync(() => hits.length),
|
calls: Effect.sync(() => hits.length),
|
||||||
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
|
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
|
||||||
|
|
|
||||||
|
|
@ -2231,6 +2231,7 @@ export class Session2 extends HeyApiClient {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
|
messageID?: string
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: {
|
model?: {
|
||||||
providerID: string
|
providerID: string
|
||||||
|
|
@ -2248,6 +2249,7 @@ export class Session2 extends HeyApiClient {
|
||||||
{ in: "path", key: "sessionID" },
|
{ in: "path", key: "sessionID" },
|
||||||
{ in: "query", key: "directory" },
|
{ in: "query", key: "directory" },
|
||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
|
{ in: "body", key: "messageID" },
|
||||||
{ in: "body", key: "agent" },
|
{ in: "body", key: "agent" },
|
||||||
{ in: "body", key: "model" },
|
{ in: "body", key: "model" },
|
||||||
{ in: "body", key: "command" },
|
{ in: "body", key: "command" },
|
||||||
|
|
|
||||||
|
|
@ -3815,6 +3815,7 @@ export type SessionCommandResponse = SessionCommandResponses[keyof SessionComman
|
||||||
|
|
||||||
export type SessionShellData = {
|
export type SessionShellData = {
|
||||||
body?: {
|
body?: {
|
||||||
|
messageID?: string
|
||||||
agent: string
|
agent: string
|
||||||
model?: {
|
model?: {
|
||||||
providerID: string
|
providerID: string
|
||||||
|
|
|
||||||
|
|
@ -3942,6 +3942,10 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"messageID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^msg.*"
|
||||||
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createEffect, createSignal, on, onCleanup } from "solid-js"
|
import { createEffect, on, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { createEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
|
||||||
export interface AutoScrollOptions {
|
export interface AutoScrollOptions {
|
||||||
|
|
@ -11,7 +11,6 @@ export interface AutoScrollOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAutoScroll(options: AutoScrollOptions) {
|
export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
let scroll: HTMLElement | undefined
|
|
||||||
let settling = false
|
let settling = false
|
||||||
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let autoTimer: ReturnType<typeof setTimeout> | undefined
|
let autoTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
@ -21,6 +20,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
contentRef: undefined as HTMLElement | undefined,
|
contentRef: undefined as HTMLElement | undefined,
|
||||||
|
scrollRef: undefined as HTMLElement | undefined,
|
||||||
userScrolled: false,
|
userScrolled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottomNow = (behavior: ScrollBehavior) => {
|
const scrollToBottomNow = (behavior: ScrollBehavior) => {
|
||||||
const el = scroll
|
const el = store.scrollRef
|
||||||
if (!el) return
|
if (!el) return
|
||||||
markAuto(el)
|
markAuto(el)
|
||||||
if (behavior === "smooth") {
|
if (behavior === "smooth") {
|
||||||
|
|
@ -81,7 +81,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
|
|
||||||
if (force && store.userScrolled) setStore("userScrolled", false)
|
if (force && store.userScrolled) setStore("userScrolled", false)
|
||||||
|
|
||||||
const el = scroll
|
const el = store.scrollRef
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
if (!force && store.userScrolled) return
|
if (!force && store.userScrolled) return
|
||||||
|
|
@ -98,7 +98,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
const el = scroll
|
const el = store.scrollRef
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (!canScroll(el)) {
|
if (!canScroll(el)) {
|
||||||
if (store.userScrolled) setStore("userScrolled", false)
|
if (store.userScrolled) setStore("userScrolled", false)
|
||||||
|
|
@ -115,7 +115,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
// If the user is scrolling within a nested scrollable region (tool output,
|
// If the user is scrolling within a nested scrollable region (tool output,
|
||||||
// code block, etc), don't treat it as leaving the "follow bottom" mode.
|
// code block, etc), don't treat it as leaving the "follow bottom" mode.
|
||||||
// Those regions opt in via `data-scrollable`.
|
// Those regions opt in via `data-scrollable`.
|
||||||
const el = scroll
|
const el = store.scrollRef
|
||||||
const target = e.target instanceof Element ? e.target : undefined
|
const target = e.target instanceof Element ? e.target : undefined
|
||||||
const nested = target?.closest("[data-scrollable]")
|
const nested = target?.closest("[data-scrollable]")
|
||||||
if (el && nested && nested !== el) return
|
if (el && nested && nested !== el) return
|
||||||
|
|
@ -123,7 +123,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const el = scroll
|
const el = store.scrollRef
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
if (!canScroll(el)) {
|
if (!canScroll(el)) {
|
||||||
|
|
@ -172,7 +172,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => store.contentRef,
|
() => store.contentRef,
|
||||||
() => {
|
() => {
|
||||||
const el = scroll
|
const el = store.scrollRef
|
||||||
if (el && !canScroll(el)) {
|
if (el && !canScroll(el)) {
|
||||||
if (store.userScrolled) setStore("userScrolled", false)
|
if (store.userScrolled) setStore("userScrolled", false)
|
||||||
return
|
return
|
||||||
|
|
@ -208,23 +208,20 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
// Track `userScrolled` even before `scrollRef` is attached, so we can
|
// Track `userScrolled` even before `scrollRef` is attached, so we can
|
||||||
// update overflow anchoring once the element exists.
|
// update overflow anchoring once the element exists.
|
||||||
store.userScrolled
|
store.userScrolled
|
||||||
const el = scroll
|
const el = store.scrollRef
|
||||||
if (!el) return
|
if (!el) return
|
||||||
updateOverflowAnchor(el)
|
updateOverflowAnchor(el)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEventListener(() => store.scrollRef, "wheel", handleWheel, { passive: true })
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (settleTimer) clearTimeout(settleTimer)
|
if (settleTimer) clearTimeout(settleTimer)
|
||||||
if (autoTimer) clearTimeout(autoTimer)
|
if (autoTimer) clearTimeout(autoTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scrollRef: (el: HTMLElement | undefined) => {
|
scrollRef: (el: HTMLElement | undefined) => setStore("scrollRef", el),
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
updateOverflowAnchor(el)
|
|
||||||
makeEventListener(el, "wheel", handleWheel, { passive: true })
|
|
||||||
},
|
|
||||||
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
|
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
|
||||||
handleScroll,
|
handleScroll,
|
||||||
handleInteraction,
|
handleInteraction,
|
||||||
|
|
|
||||||
|
|
@ -7,43 +7,35 @@ import config from "../../../../config.mjs"
|
||||||
export const console = config.console
|
export const console = config.console
|
||||||
export const email = `mailto:${config.email}`
|
export const email = `mailto:${config.email}`
|
||||||
|
|
||||||
OpenCode Go هو اشتراك منخفض التكلفة — **5 دولارات للشهر الأول**، ثم **10 دولارات/شهريًا** — يمنحك وصولًا موثوقًا إلى نماذج البرمجة المفتوحة الشهيرة.
|
OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر الأول**، ثم **$10/شهريًا** — يمنحك وصولًا موثوقًا إلى نماذج البرمجة المفتوحة الشائعة.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
OpenCode Go حاليًا في المرحلة التجريبية (beta).
|
OpenCode Go حاليًا في المرحلة التجريبية.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
يعمل Go مثل أي مزود آخر في OpenCode. أنت تشترك في OpenCode Go و
|
يعمل Go مثل أي مزود آخر في OpenCode. تشترك في OpenCode Go وتحصل على مفتاح API الخاص بك. وهو **اختياري تمامًا**، ولا تحتاج إلى استخدامه لاستخدام OpenCode.
|
||||||
تحصل على مفتاح API الخاص بك. إنه **اختياري تمامًا** ولا تحتاج إلى استخدامه
|
|
||||||
لاستخدام OpenCode.
|
|
||||||
|
|
||||||
تم تصميمه بشكل أساسي للمستخدمين الدوليين، مع نماذج مستضافة في الولايات المتحدة، والاتحاد الأوروبي، وسنغافورة من أجل وصول عالمي مستقر.
|
صُمّم أساسًا للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة لضمان وصول عالمي مستقر.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## الخلفية
|
## الخلفية
|
||||||
|
|
||||||
أصبحت النماذج المفتوحة جيدة جدًا. لقد وصلت الآن إلى أداء يقترب من
|
أصبحت النماذج المفتوحة جيدة جدًا. فهي الآن تقترب في الأداء من النماذج المملوكة في مهام البرمجة. وبما أن كثيرًا من المزودين يمكنهم تقديمها بشكل تنافسي، فهي عادةً أقل تكلفة بكثير.
|
||||||
النماذج المغلقة (المملوكة) في مهام البرمجة. ولأن العديد من المزودين يمكنهم تقديمها
|
|
||||||
بشكل تنافسي، فإنها عادة ما تكون أرخص بكثير.
|
|
||||||
|
|
||||||
ومع ذلك، قد يكون الحصول على وصول موثوق وبزمن انتقال منخفض (low latency) إليها أمرًا صعبًا. يختلف المزودون
|
ومع ذلك، قد يكون الحصول على وصول موثوق إليها بزمن استجابة منخفض أمرًا صعبًا. إذ يختلف المزودون في الجودة والتوافر.
|
||||||
في الجودة والتوافر.
|
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
لقد اختبرنا مجموعة مختارة من النماذج والمزودين الذين يعملون بشكل جيد مع OpenCode.
|
اختبرنا مجموعة مختارة من النماذج والمزودين الذين يعملون جيدًا مع OpenCode.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
لحل هذه المشكلة، قمنا ببعض الأشياء:
|
لمعالجة ذلك، قمنا بعدة أمور:
|
||||||
|
|
||||||
1. اختبرنا مجموعة مختارة من النماذج المفتوحة وتحدثنا مع فرقهم حول أفضل السبل
|
1. اختبرنا مجموعة مختارة من النماذج المفتوحة وتحدثنا مع فرقها حول أفضل طريقة لتشغيلها.
|
||||||
لإدارتها.
|
2. ثم عملنا مع عدد قليل من المزودين للتأكد من تقديم هذه النماذج بالشكل الصحيح.
|
||||||
2. ثم عملنا مع عدد قليل من المزودين للتأكد من أنه يتم تقديمها
|
3. وأخيرًا، أجرينا مقارنات معيارية لمزيج النموذج/المزود، وتوصلنا إلى قائمة نشعر بالثقة في التوصية بها.
|
||||||
بشكل صحيح.
|
|
||||||
3. أخيرًا، قمنا بعمل تقييم لأداء (benchmarked) الجمع بين النموذج/المزود وتوصلنا
|
|
||||||
إلى قائمة نشعر بالرضا في التوصية بها.
|
|
||||||
|
|
||||||
يمنحك OpenCode Go الوصول إلى هذه النماذج مقابل **5 دولارات لشهرك الأول**، ثم **10 دولارات/شهريًا**.
|
يمنحك OpenCode Go الوصول إلى هذه النماذج مقابل **$5 للشهر الأول**، ثم **$10/شهريًا**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -51,24 +43,24 @@ OpenCode Go حاليًا في المرحلة التجريبية (beta).
|
||||||
|
|
||||||
يعمل OpenCode Go مثل أي مزود آخر في OpenCode.
|
يعمل OpenCode Go مثل أي مزود آخر في OpenCode.
|
||||||
|
|
||||||
1. تقوم بتسجيل الدخول إلى **<a href={console}>OpenCode Zen</a>**، وتشترك في Go، و
|
1. تسجّل الدخول إلى **<a href={console}>OpenCode Zen</a>**، وتشترك في Go، ثم تنسخ مفتاح API الخاص بك.
|
||||||
تنسخ مفتاح API الخاص بك.
|
2. تشغّل الأمر `/connect` في TUI، وتختار `OpenCode Go`، ثم تلصق مفتاح API الخاص بك.
|
||||||
2. تقوم بتشغيل الأمر `/connect` في الـ TUI، وتختار `OpenCode Go`، وتلصق
|
3. شغّل `/models` في TUI لرؤية قائمة النماذج المتاحة عبر Go.
|
||||||
مفتاح API الخاص بك.
|
|
||||||
3. قم بتشغيل `/models` في الـ TUI لرؤية قائمة النماذج المتاحة عبر Go.
|
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
يمكن لعضو واحد فقط لكل مساحة عمل (workspace) الاشتراك في OpenCode Go.
|
يمكن لعضو واحد فقط في كل workspace الاشتراك في OpenCode Go.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
تتضمن القائمة الحالية للنماذج:
|
تشمل قائمة النماذج الحالية:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
قد تتغير قائمة النماذج كلما اختبرنا وأضفنا نماذج جديدة.
|
قد تتغير قائمة النماذج مع استمرارنا في اختبار نماذج جديدة وإضافتها.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -76,72 +68,72 @@ OpenCode Go حاليًا في المرحلة التجريبية (beta).
|
||||||
|
|
||||||
يتضمن OpenCode Go الحدود التالية:
|
يتضمن OpenCode Go الحدود التالية:
|
||||||
|
|
||||||
- **حد الـ 5 ساعات** — 12 دولارًا من الاستخدام
|
- **حد 5 ساعات** — استخدام بقيمة $12
|
||||||
- **الحد الأسبوعي** — 30 دولارًا من الاستخدام
|
- **الحد الأسبوعي** — استخدام بقيمة $30
|
||||||
- **الحد الشهري** — 60 دولارًا من الاستخدام
|
- **الحد الشهري** — استخدام بقيمة $60
|
||||||
|
|
||||||
يتم تحديد الحدود بقيمة الدولار. هذا يعني أن عدد طلباتك الفعلي يعتمد على النموذج الذي تستخدمه. تسمح النماذج الأرخص مثل MiniMax M2.5 بمزيد من الطلبات، بينما تسمح النماذج الأعلى تكلفة مثل GLM-5 بطلبات أقل.
|
تُحدَّد الحدود بالقيمة بالدولار. وهذا يعني أن عدد طلباتك الفعلي يعتمد على النموذج الذي تستخدمه. تتيح النماذج الأقل تكلفة مثل MiniMax M2.5 عددًا أكبر من الطلبات، بينما تتيح النماذج الأعلى تكلفة مثل GLM-5 عددًا أقل.
|
||||||
|
|
||||||
يقدم الجدول أدناه عدد الطلبات التقديري بناءً على أنماط استخدام Go النموذجية:
|
يوضح الجدول أدناه عددًا تقديريًا للطلبات بناءً على أنماط استخدام Go المعتادة:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ------------------- | ----- | --------- | ------------ | ------------ |
|
| ------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| الطلبات لكل 5 ساعات | 1,150 | 1,850 | 14,000 | 20,000 |
|
| الطلبات لكل 5 ساعات | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| الطلبات في الأسبوع | 2,880 | 4,630 | 35,000 | 50,000 |
|
| الطلبات في الأسبوع | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| الطلبات في الشهر | 5,750 | 9,250 | 70,000 | 100,000 |
|
| الطلبات في الشهر | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
تستند التقديرات إلى أنماط الطلب المتوسطة الملحوظة:
|
تستند التقديرات إلى متوسطات أنماط الطلبات المرصودة:
|
||||||
|
|
||||||
- GLM-5 — 700 إدخال (input)، 52,000 مخبأة (cached)، 150 توكن إخراج (output tokens) لكل طلب
|
- GLM-5 — 700 input، و52,000 cached، و150 output tokens لكل طلب
|
||||||
- Kimi K2.5 — 870 إدخال، 55,000 مخبأة، 200 توكن إخراج لكل طلب
|
- Kimi K2.5 — 870 input، و55,000 cached، و200 output tokens لكل طلب
|
||||||
- MiniMax M2.7/M2.5 — 300 إدخال، 55,000 مخبأة، 125 توكن إخراج لكل طلب
|
- MiniMax M2.7/M2.5 — 300 input، و55,000 cached، و125 output tokens لكل طلب
|
||||||
|
- MiMo-V2-Pro — 350 input، و41,000 cached، و250 output tokens لكل طلب
|
||||||
|
- MiMo-V2-Omni — 1000 input، و60,000 cached، و140 output tokens لكل طلب
|
||||||
|
|
||||||
يمكنك تتبع استخدامك الحالي في **<a href={console}>وحدة التحكم (console)</a>**.
|
يمكنك تتبّع استخدامك الحالي في **<a href={console}>console</a>**.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
إذا وصلت إلى حد الاستخدام، يمكنك الاستمرار في استخدام النماذج المجانية.
|
إذا وصلت إلى حد الاستخدام، يمكنك الاستمرار في استخدام النماذج المجانية.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
قد تتغير حدود الاستخدام كلما تعلمنا من الاستخدام المبكر والملاحظات.
|
قد تتغير حدود الاستخدام مع تعلّمنا من الاستخدام المبكر والملاحظات.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### الاستخدام متجاوزًا الحدود
|
### الاستخدام بعد تجاوز الحدود
|
||||||
|
|
||||||
إذا كان لديك أيضًا أرصدة في رصيد Zen الخاص بك، فيمكنك تمكين خيار **Use balance**
|
إذا كان لديك أيضًا رصيد في Zen balance، فيمكنك تفعيل خيار **Use balance** في console. عند تفعيله، سيعود Go إلى استخدام Zen balance الخاص بك بعد بلوغ حدود الاستخدام بدلًا من حظر الطلبات.
|
||||||
في وحدة التحكم. عند التمكين، سيعود Go لاستخدام رصيد Zen الخاص بك
|
|
||||||
بعد أن تصل إلى حدود استخدامك بدلاً من حظر الطلبات.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## نقاط النهاية (Endpoints)
|
## نقاط النهاية
|
||||||
|
|
||||||
يمكنك أيضًا الوصول إلى نماذج Go من خلال نقاط نهاية API التالية.
|
يمكنك أيضًا الوصول إلى نماذج Go عبر نقاط نهاية API التالية.
|
||||||
|
|
||||||
| Model | Model ID | Endpoint | AI SDK Package |
|
| Model | Model ID | Endpoint | AI SDK Package |
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
يستخدم [معرف النموذج (model id)](/docs/config/#models) في إعدادات (config) OpenCode
|
يستخدم [model id](/docs/config/#models) في إعدادات OpenCode لديك التنسيق `opencode-go/<model-id>`. على سبيل المثال، بالنسبة إلى Kimi K2.5، ستستخدم `opencode-go/kimi-k2.5` في إعداداتك.
|
||||||
الخاصة بك التنسيق `opencode-go/<model-id>`. على سبيل المثال، بالنسبة لـ Kimi K2.5، ستستخدم
|
|
||||||
`opencode-go/kimi-k2.5` في الـ config الخاص بك.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## الخصوصية
|
## الخصوصية
|
||||||
|
|
||||||
صُمِّمت الخطة أساسًا للمستخدمين الدوليين، مع استضافة النماذج في US وEU وSingapore لضمان وصول عالمي مستقر. ويتّبع مزوّدونا سياسة عدم الاحتفاظ بالبيانات، ولا يستخدمون بياناتك في تدريب النماذج.
|
صُمّمت هذه الخطة أساسًا للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة لضمان وصول عالمي مستقر. ويتّبع مزودونا سياسة عدم الاحتفاظ بالبيانات، ولا يستخدمون بياناتك لتدريب النماذج.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## الأهداف
|
## الأهداف
|
||||||
|
|
||||||
لقد أنشأنا OpenCode Go من أجل:
|
أنشأنا OpenCode Go من أجل:
|
||||||
|
|
||||||
1. جعل البرمجة بالذكاء الاصطناعي **متاحة** لعدد أكبر من الأشخاص باشتراك منخفض التكلفة.
|
1. جعل البرمجة بالذكاء الاصطناعي **متاحة** لعدد أكبر من الأشخاص عبر اشتراك منخفض التكلفة.
|
||||||
2. توفير وصول **موثوق** إلى أفضل نماذج البرمجة المفتوحة.
|
2. توفير وصول **موثوق** إلى أفضل نماذج البرمجة المفتوحة.
|
||||||
3. تنسيق النماذج **المختبرة والمقيمة** (benchmarked) للاستخدام مع وكلاء البرمجة (coding agents).
|
3. تنسيق نماذج **مختبرة وخضعت لمقارنات معيارية** لاستخدامها مع وكلاء البرمجة.
|
||||||
4. **عدم تقييدك** (no lock-in) من خلال السماح لك باستخدام أي مزود آخر مع OpenCode أيضًا.
|
4. **عدم فرض قيود ارتباط** من خلال السماح لك أيضًا باستخدام أي مزود آخر مع OpenCode.
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ Trenutna lista modela uključuje:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -84,17 +86,19 @@ Ograničenja su definisana u dolarskoj vrijednosti. To znači da vaš stvarni br
|
||||||
|
|
||||||
Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca korištenja Go pretplate:
|
Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca korištenja Go pretplate:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ------------------ | ----- | --------- | ------------ | ------------ |
|
| ------------------ | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| zahtjeva na 5 sati | 1,150 | 1,850 | 14,000 | 20,000 |
|
| zahtjeva na 5 sati | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| zahtjeva sedmično | 2,880 | 4,630 | 35,000 | 50,000 |
|
| zahtjeva sedmično | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| zahtjeva mjesečno | 5,750 | 9,250 | 70,000 | 100,000 |
|
| zahtjeva mjesečno | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva:
|
Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva:
|
||||||
|
|
||||||
- GLM-5 — 700 ulaznih (input), 52,000 keširanih, 150 izlaznih (output) tokena po zahtjevu
|
- GLM-5 — 700 ulaznih (input), 52,000 keširanih, 150 izlaznih (output) tokena po zahtjevu
|
||||||
- Kimi K2.5 — 870 ulaznih, 55,000 keširanih, 200 izlaznih tokena po zahtjevu
|
- Kimi K2.5 — 870 ulaznih, 55,000 keširanih, 200 izlaznih tokena po zahtjevu
|
||||||
- MiniMax M2.7/M2.5 — 300 ulaznih, 55,000 keširanih, 125 izlaznih tokena po zahtjevu
|
- MiniMax M2.7/M2.5 — 300 ulaznih, 55,000 keširanih, 125 izlaznih tokena po zahtjevu
|
||||||
|
- MiMo-V2-Pro — 350 ulaznih, 41,000 keširanih, 250 izlaznih tokena po zahtjevu
|
||||||
|
- MiMo-V2-Omni — 1000 ulaznih, 60,000 keširanih, 140 izlaznih tokena po zahtjevu
|
||||||
|
|
||||||
Svoju trenutnu potrošnju možete pratiti u **<a href={console}>konzoli</a>**.
|
Svoju trenutnu potrošnju možete pratiti u **<a href={console}>konzoli</a>**.
|
||||||
|
|
||||||
|
|
@ -122,6 +126,8 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa.
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,10 @@ Config sources are loaded in this order (later sources override earlier ones):
|
||||||
4. **Project config** (`opencode.json` in project) - project-specific settings
|
4. **Project config** (`opencode.json` in project) - project-specific settings
|
||||||
5. **`.opencode` directories** - agents, commands, plugins
|
5. **`.opencode` directories** - agents, commands, plugins
|
||||||
6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides
|
6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides
|
||||||
|
7. **Managed config files** (`/Library/Application Support/opencode/` on macOS) - admin-controlled
|
||||||
|
8. **macOS managed preferences** (`.mobileconfig` via MDM) - highest priority, not user-overridable
|
||||||
|
|
||||||
This means project configs can override global defaults, and global configs can override remote organizational defaults.
|
This means project configs can override global defaults, and global configs can override remote organizational defaults. Managed settings override everything.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility.
|
The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility.
|
||||||
|
|
@ -149,6 +151,106 @@ The custom directory is loaded after the global config and `.opencode` directori
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Managed settings
|
||||||
|
|
||||||
|
Organizations can enforce configuration that users cannot override. Managed settings are loaded at the highest priority tier.
|
||||||
|
|
||||||
|
#### File-based
|
||||||
|
|
||||||
|
Drop an `opencode.json` or `opencode.jsonc` file in the system managed config directory:
|
||||||
|
|
||||||
|
| Platform | Path |
|
||||||
|
| -------- | ---------------------------------------- |
|
||||||
|
| macOS | `/Library/Application Support/opencode/` |
|
||||||
|
| Linux | `/etc/opencode/` |
|
||||||
|
| Windows | `%ProgramData%\opencode` |
|
||||||
|
|
||||||
|
These directories require admin/root access to write, so users cannot modify them.
|
||||||
|
|
||||||
|
#### macOS managed preferences
|
||||||
|
|
||||||
|
On macOS, OpenCode reads managed preferences from the `ai.opencode.managed` preference domain. Deploy a `.mobileconfig` via MDM (Jamf, Kandji, FleetDM) and the settings are enforced automatically.
|
||||||
|
|
||||||
|
OpenCode checks these paths:
|
||||||
|
|
||||||
|
1. `/Library/Managed Preferences/<user>/ai.opencode.managed.plist`
|
||||||
|
2. `/Library/Managed Preferences/ai.opencode.managed.plist`
|
||||||
|
|
||||||
|
The plist keys map directly to `opencode.json` fields. MDM metadata keys (`PayloadUUID`, `PayloadType`, etc.) are stripped automatically.
|
||||||
|
|
||||||
|
**Creating a `.mobileconfig`**
|
||||||
|
|
||||||
|
Use the `ai.opencode.managed` PayloadType. The OpenCode config keys go directly in the payload dict:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>ai.opencode.managed</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>com.example.opencode.config</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>GENERATE-YOUR-OWN-UUID</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>share</key>
|
||||||
|
<string>disabled</string>
|
||||||
|
<key>server</key>
|
||||||
|
<dict>
|
||||||
|
<key>hostname</key>
|
||||||
|
<string>127.0.0.1</string>
|
||||||
|
</dict>
|
||||||
|
<key>permission</key>
|
||||||
|
<dict>
|
||||||
|
<key>*</key>
|
||||||
|
<string>ask</string>
|
||||||
|
<key>bash</key>
|
||||||
|
<dict>
|
||||||
|
<key>*</key>
|
||||||
|
<string>ask</string>
|
||||||
|
<key>rm -rf *</key>
|
||||||
|
<string>deny</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>Configuration</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>com.example.opencode</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>GENERATE-YOUR-OWN-UUID</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate unique UUIDs with `uuidgen`. Customize the settings to match your organization's requirements.
|
||||||
|
|
||||||
|
**Deploying via MDM**
|
||||||
|
|
||||||
|
- **Jamf Pro:** Computers > Configuration Profiles > Upload > scope to target devices or smart groups
|
||||||
|
- **FleetDM:** Add the `.mobileconfig` to your gitops repo under `mdm.macos_settings.custom_settings` and run `fleetctl apply`
|
||||||
|
|
||||||
|
**Verifying on a device**
|
||||||
|
|
||||||
|
Double-click the `.mobileconfig` to install locally for testing (shows in System Settings > Privacy & Security > Profiles), then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode debug config
|
||||||
|
```
|
||||||
|
|
||||||
|
All managed preference keys appear in the resolved config and cannot be overridden by user or project configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
|
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ Den nuværende liste over modeller inkluderer:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -84,17 +86,19 @@ Grænserne er defineret i dollarværdi. Det betyder, at dit faktiske antal anmod
|
||||||
|
|
||||||
Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-forbrugsmønstre:
|
Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-forbrugsmønstre:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ----------------------- | ----- | --------- | ------------ | ------------ |
|
| ----------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| anmodninger pr. 5 timer | 1.150 | 1.850 | 14.000 | 20.000 |
|
| anmodninger pr. 5 timer | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 |
|
||||||
| anmodninger pr. uge | 2.880 | 4.630 | 35.000 | 50.000 |
|
| anmodninger pr. uge | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 |
|
||||||
| anmodninger pr. måned | 5.750 | 9.250 | 70.000 | 100.000 |
|
| anmodninger pr. måned | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 |
|
||||||
|
|
||||||
Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre:
|
Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre:
|
||||||
|
|
||||||
- GLM-5 — 700 input, 52.000 cachelagrede, 150 output-tokens pr. anmodning
|
- GLM-5 — 700 input, 52.000 cachelagrede, 150 output-tokens pr. anmodning
|
||||||
- Kimi K2.5 — 870 input, 55.000 cachelagrede, 200 output-tokens pr. anmodning
|
- Kimi K2.5 — 870 input, 55.000 cachelagrede, 200 output-tokens pr. anmodning
|
||||||
- MiniMax M2.7/M2.5 — 300 input, 55.000 cachelagrede, 125 output-tokens pr. anmodning
|
- MiniMax M2.7/M2.5 — 300 input, 55.000 cachelagrede, 125 output-tokens pr. anmodning
|
||||||
|
- MiMo-V2-Pro — 350 input, 41.000 cachelagrede, 250 output-tokens pr. anmodning
|
||||||
|
- MiMo-V2-Omni — 1000 input, 60.000 cachelagrede, 140 output-tokens pr. anmodning
|
||||||
|
|
||||||
Du kan spore dit nuværende forbrug i **<a href={console}>konsollen</a>**.
|
Du kan spore dit nuværende forbrug i **<a href={console}>konsollen</a>**.
|
||||||
|
|
||||||
|
|
@ -122,6 +126,8 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints.
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ Die aktuelle Liste der Modelle umfasst:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -76,17 +78,19 @@ Limits sind in Dollarwerten definiert. Das bedeutet, dass die tatsächliche Anza
|
||||||
|
|
||||||
Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf typischen Go-Nutzungsmustern:
|
Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf typischen Go-Nutzungsmustern:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ---------------------- | ----- | --------- | ------------ | ------------ |
|
| ---------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| Anfragen pro 5 Stunden | 1.150 | 1.850 | 14.000 | 20.000 |
|
| Anfragen pro 5 Stunden | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 |
|
||||||
| Anfragen pro Woche | 2.880 | 4.630 | 35.000 | 50.000 |
|
| Anfragen pro Woche | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 |
|
||||||
| Anfragen pro Monat | 5.750 | 9.250 | 70.000 | 100.000 |
|
| Anfragen pro Monat | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 |
|
||||||
|
|
||||||
Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern:
|
Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern:
|
||||||
|
|
||||||
- GLM-5 — 700 Input-, 52.000 Cached-, 150 Output-Tokens pro Anfrage
|
- GLM-5 — 700 Input-, 52.000 Cached-, 150 Output-Tokens pro Anfrage
|
||||||
- Kimi K2.5 — 870 Input-, 55.000 Cached-, 200 Output-Tokens pro Anfrage
|
- Kimi K2.5 — 870 Input-, 55.000 Cached-, 200 Output-Tokens pro Anfrage
|
||||||
- MiniMax M2.7/M2.5 — 300 Input-, 55.000 Cached-, 125 Output-Tokens pro Anfrage
|
- MiniMax M2.7/M2.5 — 300 Input-, 55.000 Cached-, 125 Output-Tokens pro Anfrage
|
||||||
|
- MiMo-V2-Pro — 350 Input-, 41.000 Cached-, 250 Output-Tokens pro Anfrage
|
||||||
|
- MiMo-V2-Omni — 1.000 Input-, 60.000 Cached-, 140 Output-Tokens pro Anfrage
|
||||||
|
|
||||||
Du kannst deine aktuelle Nutzung in der **<a href={console}>Console</a>** verfolgen.
|
Du kannst deine aktuelle Nutzung in der **<a href={console}>Console</a>** verfolgen.
|
||||||
|
|
||||||
|
|
@ -112,6 +116,8 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen.
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ La lista actual de modelos incluye:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -84,17 +86,19 @@ Los límites se definen en valor en dólares. Esto significa que tu cantidad rea
|
||||||
|
|
||||||
La siguiente tabla proporciona una cantidad estimada de peticiones basada en los patrones típicos de uso de Go:
|
La siguiente tabla proporciona una cantidad estimada de peticiones basada en los patrones típicos de uso de Go:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ---------------------- | ----- | --------- | ------------ | ------------ |
|
| ---------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| peticiones por 5 horas | 1,150 | 1,850 | 14,000 | 20,000 |
|
| peticiones por 5 horas | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| peticiones por semana | 2,880 | 4,630 | 35,000 | 50,000 |
|
| peticiones por semana | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| peticiones por mes | 5,750 | 9,250 | 70,000 | 100,000 |
|
| peticiones por mes | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
Las estimaciones se basan en los patrones de peticiones promedio observados:
|
Las estimaciones se basan en los patrones de peticiones promedio observados:
|
||||||
|
|
||||||
- GLM-5 — 700 tokens de entrada, 52,000 en caché, 150 tokens de salida por petición
|
- GLM-5 — 700 tokens de entrada, 52,000 en caché, 150 tokens de salida por petición
|
||||||
- Kimi K2.5 — 870 tokens de entrada, 55,000 en caché, 200 tokens de salida por petición
|
- Kimi K2.5 — 870 tokens de entrada, 55,000 en caché, 200 tokens de salida por petición
|
||||||
- MiniMax M2.7/M2.5 — 300 tokens de entrada, 55,000 en caché, 125 tokens de salida por petición
|
- MiniMax M2.7/M2.5 — 300 tokens de entrada, 55,000 en caché, 125 tokens de salida por petición
|
||||||
|
- MiMo-V2-Pro — 350 tokens de entrada, 41,000 en caché, 250 tokens de salida por petición
|
||||||
|
- MiMo-V2-Omni — 1000 tokens de entrada, 60,000 en caché, 140 tokens de salida por petición
|
||||||
|
|
||||||
Puedes realizar un seguimiento de tu uso actual en la **<a href={console}>consola</a>**.
|
Puedes realizar un seguimiento de tu uso actual en la **<a href={console}>consola</a>**.
|
||||||
|
|
||||||
|
|
@ -122,6 +126,8 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint
|
||||||
| ------------ | ------------- | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------- | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ La liste actuelle des modèles comprend :
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -74,17 +76,19 @@ Les limites sont définies en valeur monétaire (dollars). Cela signifie que vot
|
||||||
|
|
||||||
Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur des modèles d'utilisation typiques de Go :
|
Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur des modèles d'utilisation typiques de Go :
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| --------------------- | ----- | --------- | ------------ | ------------ |
|
| --------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| requêtes par 5 heures | 1,150 | 1,850 | 14,000 | 20,000 |
|
| requêtes par 5 heures | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| requêtes par semaine | 2,880 | 4,630 | 35,000 | 50,000 |
|
| requêtes par semaine | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| requêtes par mois | 5,750 | 9,250 | 70,000 | 100,000 |
|
| requêtes par mois | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
Les estimations sont basées sur les modèles de requêtes moyens observés :
|
Les estimations sont basées sur les modèles de requêtes moyens observés :
|
||||||
|
|
||||||
- GLM-5 — 700 tokens en entrée, 52,000 en cache, 150 tokens en sortie par requête
|
- GLM-5 — 700 tokens en entrée, 52,000 en cache, 150 tokens en sortie par requête
|
||||||
- Kimi K2.5 — 870 tokens en entrée, 55,000 en cache, 200 tokens en sortie par requête
|
- Kimi K2.5 — 870 tokens en entrée, 55,000 en cache, 200 tokens en sortie par requête
|
||||||
- MiniMax M2.7/M2.5 — 300 tokens en entrée, 55,000 en cache, 125 tokens en sortie par requête
|
- MiniMax M2.7/M2.5 — 300 tokens en entrée, 55,000 en cache, 125 tokens en sortie par requête
|
||||||
|
- MiMo-V2-Pro — 350 tokens en entrée, 41,000 en cache, 250 tokens en sortie par requête
|
||||||
|
- MiMo-V2-Omni — 1000 tokens en entrée, 60,000 en cache, 140 tokens en sortie par requête
|
||||||
|
|
||||||
Vous pouvez suivre votre utilisation actuelle dans la **<a href={console}>console</a>**.
|
Vous pouvez suivre votre utilisation actuelle dans la **<a href={console}>console</a>**.
|
||||||
|
|
||||||
|
|
@ -110,6 +114,8 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d'
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ The current list of models includes:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -84,17 +86,19 @@ Limits are defined in dollar value. This means your actual request count depends
|
||||||
|
|
||||||
The table below provides an estimated request count based on typical Go usage patterns:
|
The table below provides an estimated request count based on typical Go usage patterns:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ------------------- | ----- | --------- | ------------ | ------------ |
|
| ------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| requests per 5 hour | 1,150 | 1,850 | 14,000 | 20,000 |
|
| requests per 5 hour | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| requests per week | 2,880 | 4,630 | 35,000 | 50,000 |
|
| requests per week | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| requests per month | 5,750 | 9,250 | 70,000 | 100,000 |
|
| requests per month | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
Estimates are based on observed average request patterns:
|
Estimates are based on observed average request patterns:
|
||||||
|
|
||||||
- GLM-5 — 700 input, 52,000 cached, 150 output tokens per request
|
- GLM-5 — 700 input, 52,000 cached, 150 output tokens per request
|
||||||
- Kimi K2.5 — 870 input, 55,000 cached, 200 output tokens per request
|
- Kimi K2.5 — 870 input, 55,000 cached, 200 output tokens per request
|
||||||
- MiniMax M2.7/M2.5 — 300 input, 55,000 cached, 125 output tokens per request
|
- MiniMax M2.7/M2.5 — 300 input, 55,000 cached, 125 output tokens per request
|
||||||
|
- MiMo-V2-Pro — 350 input, 41,000 cached, 250 output tokens per request
|
||||||
|
- MiMo-V2-Omni — 1000 input, 60,000 cached, 140 output tokens per request
|
||||||
|
|
||||||
You can track your current usage in the **<a href={console}>console</a>**.
|
You can track your current usage in the **<a href={console}>console</a>**.
|
||||||
|
|
||||||
|
|
@ -122,6 +126,8 @@ You can also access Go models through the following API endpoints.
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ L'elenco attuale dei modelli include:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -82,17 +84,19 @@ I limiti sono definiti in valore in dollari. Questo significa che il conteggio e
|
||||||
|
|
||||||
La tabella seguente fornisce una stima del conteggio delle richieste in base a pattern di utilizzo tipici di Go:
|
La tabella seguente fornisce una stima del conteggio delle richieste in base a pattern di utilizzo tipici di Go:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| --------------------- | ----- | --------- | ------------ | ------------ |
|
| --------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| richieste ogni 5 ore | 1.150 | 1.850 | 14.000 | 20.000 |
|
| richieste ogni 5 ore | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 |
|
||||||
| richieste a settimana | 2.880 | 4.630 | 35.000 | 50.000 |
|
| richieste a settimana | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 |
|
||||||
| richieste al mese | 5.750 | 9.250 | 70.000 | 100.000 |
|
| richieste al mese | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 |
|
||||||
|
|
||||||
Le stime si basano sui pattern medi di richieste osservati:
|
Le stime si basano sui pattern medi di richieste osservati:
|
||||||
|
|
||||||
- GLM-5 — 700 di input, 52.000 in cache, 150 token di output per richiesta
|
- GLM-5 — 700 di input, 52.000 in cache, 150 token di output per richiesta
|
||||||
- Kimi K2.5 — 870 di input, 55.000 in cache, 200 token di output per richiesta
|
- Kimi K2.5 — 870 di input, 55.000 in cache, 200 token di output per richiesta
|
||||||
- MiniMax M2.7/M2.5 — 300 di input, 55.000 in cache, 125 token di output per richiesta
|
- MiniMax M2.7/M2.5 — 300 di input, 55.000 in cache, 125 token di output per richiesta
|
||||||
|
- MiMo-V2-Pro — 350 di input, 41.000 in cache, 250 token di output per richiesta
|
||||||
|
- MiMo-V2-Omni — 1000 di input, 60.000 in cache, 140 token di output per richiesta
|
||||||
|
|
||||||
Puoi monitorare il tuo utilizzo attuale nella **<a href={console}>console</a>**.
|
Puoi monitorare il tuo utilizzo attuale nella **<a href={console}>console</a>**.
|
||||||
|
|
||||||
|
|
@ -120,6 +124,8 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API.
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ OpenCode Goをサブスクライブできるのは、1つのワークスペー
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -74,17 +76,19 @@ OpenCode Goには以下の制限が含まれています:
|
||||||
|
|
||||||
以下の表は、一般的なGoの利用パターンに基づいた推定リクエスト数を示しています:
|
以下の表は、一般的なGoの利用パターンに基づいた推定リクエスト数を示しています:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ------------------------- | ----- | --------- | ------------ | ------------ |
|
| ------------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| 5時間あたりのリクエスト数 | 1,150 | 1,850 | 14,000 | 20,000 |
|
| 5時間あたりのリクエスト数 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| 週間リクエスト数 | 2,880 | 4,630 | 35,000 | 50,000 |
|
| 週間リクエスト数 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| 月間リクエスト数 | 5,750 | 9,250 | 70,000 | 100,000 |
|
| 月間リクエスト数 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
推定値は、観測された平均的なリクエストパターンに基づいています:
|
推定値は、観測された平均的なリクエストパターンに基づいています:
|
||||||
|
|
||||||
- GLM-5 — リクエストあたり 入力 700トークン、キャッシュ 52,000トークン、出力 150トークン
|
- GLM-5 — リクエストあたり 入力 700トークン、キャッシュ 52,000トークン、出力 150トークン
|
||||||
- Kimi K2.5 — リクエストあたり 入力 870トークン、キャッシュ 55,000トークン、出力 200トークン
|
- Kimi K2.5 — リクエストあたり 入力 870トークン、キャッシュ 55,000トークン、出力 200トークン
|
||||||
- MiniMax M2.7/M2.5 — リクエストあたり 入力 300トークン、キャッシュ 55,000トークン、出力 125トークン
|
- MiniMax M2.7/M2.5 — リクエストあたり 入力 300トークン、キャッシュ 55,000トークン、出力 125トークン
|
||||||
|
- MiMo-V2-Pro — リクエストあたり 入力 350トークン、キャッシュ 41,000トークン、出力 250トークン
|
||||||
|
- MiMo-V2-Omni — リクエストあたり 入力 1000トークン、キャッシュ 60,000トークン、出力 140トークン
|
||||||
|
|
||||||
現在の利用状況は**<a href={console}>コンソール</a>**で追跡できます。
|
現在の利用状況は**<a href={console}>コンソール</a>**で追跡できます。
|
||||||
|
|
||||||
|
|
@ -110,6 +114,8 @@ Zen残高にクレジットがある場合は、コンソールで**Use balance*
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,98 +7,102 @@ import config from "../../../../config.mjs"
|
||||||
export const console = config.console
|
export const console = config.console
|
||||||
export const email = `mailto:${config.email}`
|
export const email = `mailto:${config.email}`
|
||||||
|
|
||||||
OpenCode Go는 인기 있는 오픈 코딩 모델에 안정적으로 액세스할 수 있도록 제공하는 저비용 구독 서비스입니다. **첫 달은 $5**이며 이후에는 **월 $10**입니다.
|
OpenCode Go는 인기 있는 오픈 코딩 모델에 안정적으로 액세스할 수 있게 해주는 저비용 구독 서비스입니다. **첫 달은 $5**, 이후에는 **월 $10**입니다.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
OpenCode Go는 현재 베타 버전입니다.
|
OpenCode Go는 현재 베타입니다.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Go는 OpenCode의 다른 제공자와 동일하게 작동합니다. OpenCode Go를 구독하고 API 키를 발급받으면 됩니다. 이는 **완전히 선택 사항**이며, OpenCode를 사용하기 위해 반드시 사용할 필요는 없습니다.
|
Go는 OpenCode의 다른 제공자와 똑같이 작동합니다. OpenCode Go를 구독하고 API 키를 발급받으면 됩니다. 이는 **완전히 선택 사항**이며, OpenCode를 사용하기 위해 꼭 필요하지는 않습니다.
|
||||||
|
|
||||||
주로 글로벌 사용자를 위해 설계되었으며, 안정적인 전 세계 액세스를 위해 모델은 미국, EU 및 싱가포르에 호스팅됩니다.
|
주로 해외 사용자를 위해 설계되었으며, 안정적인 전 세계 액세스를 위해 모델은 미국, EU, 싱가포르에 호스팅됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 배경
|
## 배경
|
||||||
|
|
||||||
오픈 모델은 매우 발전했습니다. 이제 코딩 작업에서 독점 모델에 근접한 성능을 보여줍니다. 또한 많은 제공자가 경쟁적으로 서비스하기 때문에 일반적으로 비용이 훨씬 저렴합니다.
|
오픈 모델은 정말 많이 발전했습니다. 이제는 코딩 작업에서 독점 모델에 가까운 성능을 보여줍니다. 그리고 많은 제공자가 경쟁력 있게 서비스를 제공할 수 있기 때문에, 보통 훨씬 더 저렴합니다.
|
||||||
|
|
||||||
하지만 안정적이고 지연 시간이 짧은 액세스를 확보하는 것은 어려울 수 있습니다. 제공자마다 품질과 가용성이 다르기 때문입니다.
|
하지만 이런 모델에 안정적이고 낮은 지연 시간으로 액세스하는 일은 쉽지 않을 수 있습니다. 제공자마다 품질과 가용성이 다르기 때문입니다.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
OpenCode와 잘 작동하는 선별된 모델 및 제공자 그룹을 테스트했습니다.
|
OpenCode와 잘 맞는 선별된 모델과 제공자 그룹을 테스트했습니다.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
이 문제를 해결하기 위해 다음 몇 가지 작업을 수행했습니다.
|
이를 해결하기 위해, 저희는 몇 가지를 진행했습니다.
|
||||||
|
|
||||||
1. 선별된 오픈 모델 그룹을 테스트하고 각 팀과 최적의 실행 방법에 대해 논의했습니다.
|
1. 선별된 오픈 모델 그룹을 테스트하고, 각 팀과 가장 잘 운영하는 방법에 대해 논의했습니다.
|
||||||
2. 그런 다음 몇몇 제공자와 협력하여 모델이 올바르게 서비스되고 있는지 확인했습니다.
|
2. 그런 다음 몇몇 제공자와 협력해, 이 모델들이 올바르게 서비스되도록 했습니다.
|
||||||
3. 마지막으로 모델/제공자 조합을 벤치마킹하여 자신 있게 추천할 수 있는 목록을 작성했습니다.
|
3. 마지막으로 모델/제공자 조합을 벤치마킹해, 자신 있게 추천할 수 있는 목록을 만들었습니다.
|
||||||
|
|
||||||
OpenCode Go를 사용하면 **첫 달 $5**, 이후 **월 $10**의 가격으로 이러한 모델에 액세스할 수 있습니다.
|
OpenCode Go를 사용하면 **첫 달은 $5**, 이후에는 **월 $10**으로 이러한 모델에 액세스할 수 있습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 작동 방식
|
## 작동 방식
|
||||||
|
|
||||||
OpenCode Go는 OpenCode의 다른 제공자와 동일하게 작동합니다.
|
OpenCode Go는 OpenCode의 다른 제공자와 똑같이 작동합니다.
|
||||||
|
|
||||||
1. **<a href={console}>OpenCode Zen</a>**에 로그인하여 Go를 구독하고 API 키를 복사합니다.
|
1. **<a href={console}>OpenCode Zen</a>**에 로그인해 Go를 구독하고 API 키를 복사합니다.
|
||||||
2. TUI에서 `/connect` 명령을 실행하고 `OpenCode Go`를 선택한 후 API 키를 붙여넣습니다.
|
2. TUI에서 `/connect` 명령을 실행하고 `OpenCode Go`를 선택한 다음 API 키를 붙여넣습니다.
|
||||||
3. TUI에서 `/models`를 실행하여 Go를 통해 사용할 수 있는 모델 목록을 확인합니다.
|
3. TUI에서 `/models`를 실행해 Go를 통해 사용할 수 있는 모델 목록을 확인합니다.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
작업 공간당 한 명의 구성원만 OpenCode Go를 구독할 수 있습니다.
|
workspace당 한 명의 멤버만 OpenCode Go를 구독할 수 있습니다.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
현재 지원되는 모델 목록은 다음과 같습니다.
|
현재 모델 목록에는 다음이 포함됩니다.
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
모델 목록은 테스트 및 추가 상황에 따라 변경될 수 있습니다.
|
새로운 모델을 테스트하고 추가함에 따라 이 목록은 변경될 수 있습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 사용 한도
|
## 사용 한도
|
||||||
|
|
||||||
OpenCode Go에는 다음과 같은 한도가 적용됩니다.
|
OpenCode Go에는 다음과 같은 한도가 포함됩니다.
|
||||||
|
|
||||||
- **5시간 한도** — 사용 금액 $12
|
- **5시간 한도** — 사용량 $12
|
||||||
- **주간 한도** — 사용 금액 $30
|
- **주간 한도** — 사용량 $30
|
||||||
- **월간 한도** — 사용 금액 $60
|
- **월간 한도** — 사용량 $60
|
||||||
|
|
||||||
한도는 달러 가치로 정의됩니다. 즉, 실제 요청 횟수는 사용하는 모델에 따라 달라집니다. MiniMax M2.5와 같이 저렴한 모델은 더 많은 요청이 가능한 반면, GLM-5와 같이 비용이 더 높은 모델은 더 적은 요청이 가능합니다.
|
한도는 달러 금액 기준으로 정의됩니다. 즉, 실제 요청 횟수는 사용하는 모델에 따라 달라집니다. MiniMax M2.5처럼 저렴한 모델은 더 많은 요청이 가능하고, GLM-5처럼 비용이 더 높은 모델은 더 적은 요청이 가능합니다.
|
||||||
|
|
||||||
아래 표는 일반적인 Go 사용 패턴을 기반으로 한 예상 요청 횟수를 보여줍니다.
|
아래 표는 일반적인 Go 사용 패턴을 기준으로 한 예상 요청 횟수를 보여줍니다.
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ----------------- | ----- | --------- | ------------ | ------------ |
|
| ----------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| 5시간당 요청 횟수 | 1,150 | 1,850 | 14,000 | 20,000 |
|
| 5시간당 요청 횟수 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| 주당 요청 횟수 | 2,880 | 4,630 | 35,000 | 50,000 |
|
| 주간 요청 횟수 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| 월당 요청 횟수 | 5,750 | 9,250 | 70,000 | 100,000 |
|
| 월간 요청 횟수 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
예상치는 관찰된 평균 요청 패턴을 기준으로 합니다.
|
예상치는 관찰된 평균 요청 패턴을 기준으로 합니다.
|
||||||
|
|
||||||
- GLM-5 — 요청당 700 입력, 52,000 캐시됨, 150 출력 토큰
|
- GLM-5 — 요청당 입력 700, 캐시 52,000, 출력 토큰 150
|
||||||
- Kimi K2.5 — 요청당 870 입력, 55,000 캐시됨, 200 출력 토큰
|
- Kimi K2.5 — 요청당 입력 870, 캐시 55,000, 출력 토큰 200
|
||||||
- MiniMax M2.7/M2.5 — 요청당 300 입력, 55,000 캐시됨, 125 출력 토큰
|
- MiniMax M2.7/M2.5 — 요청당 입력 300, 캐시 55,000, 출력 토큰 125
|
||||||
|
- MiMo-V2-Pro — 요청당 입력 350, 캐시 41,000, 출력 토큰 250
|
||||||
|
- MiMo-V2-Omni — 요청당 입력 1000, 캐시 60,000, 출력 토큰 140
|
||||||
|
|
||||||
**<a href={console}>콘솔</a>**에서 현재 사용량을 추적할 수 있습니다.
|
현재 사용량은 **<a href={console}>console</a>**에서 확인할 수 있습니다.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
사용 한도에 도달하더라도 무료 모델은 계속 사용할 수 있습니다.
|
사용 한도에 도달해도 무료 모델은 계속 사용할 수 있습니다.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
사용 한도는 초기 사용 데이터 및 피드백에 따라 변경될 수 있습니다.
|
초기 사용량과 피드백을 바탕으로 사용 한도는 변경될 수 있습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 한도 초과 사용
|
### 한도 초과 사용
|
||||||
|
|
||||||
Zen 잔액에 크레딧이 있는 경우 콘솔에서 **잔액 사용(Use balance)** 옵션을 활성화할 수 있습니다. 이 기능을 활성화하면 사용 한도에 도달한 후 요청을 차단하는 대신 Zen 잔액을 대체하여 사용하게 됩니다.
|
Zen 잔액에 크레딧도 있다면, console에서 **Use balance** 옵션을 활성화할 수 있습니다. 이 옵션을 켜면, 사용 한도에 도달했을 때 요청을 차단하는 대신 Go가 Zen 잔액으로 자동 전환됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -110,24 +114,26 @@ Zen 잔액에 크레딧이 있는 경우 콘솔에서 **잔액 사용(Use balanc
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
OpenCode 구성의 [모델 ID](/docs/config/#models)는 `opencode-go/<model-id>` 형식을 사용합니다. 예를 들어 Kimi K2.5의 경우, 구성에서 `opencode-go/kimi-k2.5`를 사용해야 합니다.
|
OpenCode config의 [model id](/docs/config/#models)는 `opencode-go/<model-id>` 형식을 사용합니다. 예를 들어 Kimi K2.5의 경우 config에서 `opencode-go/kimi-k2.5`를 사용하면 됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 개인정보 보호
|
## 개인정보 보호
|
||||||
|
|
||||||
이 플랜은 전 세계에서 안정적으로 사용할 수 있도록 US, EU, Singapore에 모델을 호스팅하며, 주로 해외 사용자를 위해 설계되었습니다. 당사의 제공업체는 zero-retention 정책을 따르며, 고객 데이터를 모델 학습에 사용하지 않습니다.
|
이 플랜은 안정적인 전 세계 액세스를 위해 모델을 미국, EU, 싱가포르에 호스팅하며, 주로 해외 사용자를 위해 설계되었습니다. 저희 제공자는 zero-retention 정책을 따르며, 고객 데이터를 모델 학습에 사용하지 않습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 목표
|
## 목표
|
||||||
|
|
||||||
OpenCode Go의 목표는 다음과 같습니다.
|
OpenCode Go를 만든 목적은 다음과 같습니다.
|
||||||
|
|
||||||
1. 저비용 구독을 통해 더 많은 사람들이 AI 코딩에 **접근 가능**하도록 합니다.
|
1. 저비용 구독으로 더 많은 사람이 AI 코딩을 **접근 가능하게** 이용할 수 있도록 합니다.
|
||||||
2. 최고의 오픈 코딩 모델에 대한 **안정적인** 액세스를 제공합니다.
|
2. 최고의 오픈 코딩 모델에 **안정적으로** 액세스할 수 있게 합니다.
|
||||||
3. 코딩 에이전트 사용을 위해 **테스트 및 벤치마킹**된 모델을 선별합니다.
|
3. 코딩 에이전트 사용에 맞게 **테스트되고 벤치마킹된** 모델을 선별합니다.
|
||||||
4. OpenCode에서 다른 제공자도 함께 사용할 수 있도록 허용하여 **종속성을 없앱니다(no lock-in)**.
|
4. OpenCode에서 다른 제공자도 함께 사용할 수 있게 해 **종속성 없음**을 유지합니다.
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ Den nåværende listen over modeller inkluderer:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -84,17 +86,19 @@ Grensene er definert i dollarverdi. Dette betyr at ditt faktiske antall forespø
|
||||||
|
|
||||||
Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksmønstre for Go:
|
Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksmønstre for Go:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ------------------------ | ----- | --------- | ------------ | ------------ |
|
| ------------------------ | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| forespørsler per 5 timer | 1 150 | 1 850 | 14 000 | 20 000 |
|
| forespørsler per 5 timer | 1 150 | 1 850 | 1 290 | 2 150 | 14 000 | 20 000 |
|
||||||
| forespørsler per uke | 2 880 | 4 630 | 35 000 | 50 000 |
|
| forespørsler per uke | 2 880 | 4 630 | 3 225 | 5 450 | 35 000 | 50 000 |
|
||||||
| forespørsler per måned | 5 750 | 9 250 | 70 000 | 100 000 |
|
| forespørsler per måned | 5 750 | 9 250 | 6 450 | 10 900 | 70 000 | 100 000 |
|
||||||
|
|
||||||
Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre:
|
Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre:
|
||||||
|
|
||||||
- GLM-5 — 700 input, 52 000 bufret, 150 output-tokens per forespørsel
|
- GLM-5 — 700 input, 52 000 bufret, 150 output-tokens per forespørsel
|
||||||
- Kimi K2.5 — 870 input, 55 000 bufret, 200 output-tokens per forespørsel
|
- Kimi K2.5 — 870 input, 55 000 bufret, 200 output-tokens per forespørsel
|
||||||
- MiniMax M2.7/M2.5 — 300 input, 55 000 bufret, 125 output-tokens per forespørsel
|
- MiniMax M2.7/M2.5 — 300 input, 55 000 bufret, 125 output-tokens per forespørsel
|
||||||
|
- MiMo-V2-Pro — 350 input, 41 000 bufret, 250 output-tokens per forespørsel
|
||||||
|
- MiMo-V2-Omni — 1000 input, 60 000 bufret, 140 output-tokens per forespørsel
|
||||||
|
|
||||||
Du kan spore din nåværende bruk i **<a href={console}>konsollen</a>**.
|
Du kan spore din nåværende bruk i **<a href={console}>konsollen</a>**.
|
||||||
|
|
||||||
|
|
@ -122,6 +126,8 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter.
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ Obecna lista modeli obejmuje:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -78,17 +80,19 @@ Limity są zdefiniowane w wartości w dolarach. Oznacza to, że rzeczywista licz
|
||||||
|
|
||||||
Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych wzorców korzystania z Go:
|
Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych wzorców korzystania z Go:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ------------------- | ----- | --------- | ------------ | ------------ |
|
| ------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| żądania na 5 godzin | 1,150 | 1,850 | 14,000 | 20,000 |
|
| żądania na 5 godzin | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| żądania na tydzień | 2,880 | 4,630 | 35,000 | 50,000 |
|
| żądania na tydzień | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| żądania na miesiąc | 5,750 | 9,250 | 70,000 | 100,000 |
|
| żądania na miesiąc | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
Szacunki opierają się na zaobserwowanych średnich wzorcach żądań:
|
Szacunki opierają się na zaobserwowanych średnich wzorcach żądań:
|
||||||
|
|
||||||
- GLM-5 — 700 tokenów wejściowych, 52 000 w pamięci podręcznej, 150 tokenów wyjściowych na żądanie
|
- GLM-5 — 700 tokenów wejściowych, 52 000 w pamięci podręcznej, 150 tokenów wyjściowych na żądanie
|
||||||
- Kimi K2.5 — 870 tokenów wejściowych, 55 000 w pamięci podręcznej, 200 tokenów wyjściowych na żądanie
|
- Kimi K2.5 — 870 tokenów wejściowych, 55 000 w pamięci podręcznej, 200 tokenów wyjściowych na żądanie
|
||||||
- MiniMax M2.7/M2.5 — 300 tokenów wejściowych, 55 000 w pamięci podręcznej, 125 tokenów wyjściowych na żądanie
|
- MiniMax M2.7/M2.5 — 300 tokenów wejściowych, 55 000 w pamięci podręcznej, 125 tokenów wyjściowych na żądanie
|
||||||
|
- MiMo-V2-Pro — 350 tokenów wejściowych, 41 000 w pamięci podręcznej, 250 tokenów wyjściowych na żądanie
|
||||||
|
- MiMo-V2-Omni — 1000 tokenów wejściowych, 60 000 w pamięci podręcznej, 140 tokenów wyjściowych na żądanie
|
||||||
|
|
||||||
Możesz śledzić swoje bieżące zużycie w **<a href={console}>konsoli</a>**.
|
Możesz śledzić swoje bieżące zużycie w **<a href={console}>konsoli</a>**.
|
||||||
|
|
||||||
|
|
@ -114,6 +118,8 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ A lista atual de modelos inclui:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -84,17 +86,19 @@ Os limites são definidos em valor em dólares. Isso significa que a sua contage
|
||||||
|
|
||||||
A tabela abaixo fornece uma contagem estimada de requisições com base nos padrões típicos de uso do Go:
|
A tabela abaixo fornece uma contagem estimada de requisições com base nos padrões típicos de uso do Go:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| -------------------------- | ----- | --------- | ------------ | ------------ |
|
| ----------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| requisições a cada 5 horas | 1.150 | 1.850 | 14.000 | 20.000 |
|
| requisições por 5 horas | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 |
|
||||||
| requisições por semana | 2.880 | 4.630 | 35.000 | 50.000 |
|
| requisições por semana | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 |
|
||||||
| requisições por mês | 5.750 | 9.250 | 70.000 | 100.000 |
|
| requisições por mês | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 |
|
||||||
|
|
||||||
As estimativas baseiam-se nos padrões médios de requisições observados:
|
As estimativas baseiam-se nos padrões médios de requisições observados:
|
||||||
|
|
||||||
- GLM-5 — 700 tokens de entrada, 52.000 em cache, 150 tokens de saída por requisição
|
- GLM-5 — 700 tokens de entrada, 52.000 em cache, 150 tokens de saída por requisição
|
||||||
- Kimi K2.5 — 870 tokens de entrada, 55.000 em cache, 200 tokens de saída por requisição
|
- Kimi K2.5 — 870 tokens de entrada, 55.000 em cache, 200 tokens de saída por requisição
|
||||||
- MiniMax M2.7/M2.5 — 300 tokens de entrada, 55.000 em cache, 125 tokens de saída por requisição
|
- MiniMax M2.7/M2.5 — 300 tokens de entrada, 55.000 em cache, 125 tokens de saída por requisição
|
||||||
|
- MiMo-V2-Pro — 350 tokens de entrada, 41.000 em cache, 250 tokens de saída por requisição
|
||||||
|
- MiMo-V2-Omni — 1000 tokens de entrada, 60.000 em cache, 140 tokens de saída por requisição
|
||||||
|
|
||||||
Você pode acompanhar o seu uso atual no **<a href={console}>console</a>**.
|
Você pode acompanhar o seu uso atual no **<a href={console}>console</a>**.
|
||||||
|
|
||||||
|
|
@ -122,6 +126,8 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ OpenCode Go работает так же, как и любой другой пр
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -84,17 +86,19 @@ OpenCode Go включает следующие лимиты:
|
||||||
|
|
||||||
В таблице ниже приведено примерное количество запросов на основе типичных сценариев использования Go:
|
В таблице ниже приведено примерное количество запросов на основе типичных сценариев использования Go:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ------------------- | ----- | --------- | ------------ | ------------ |
|
| ------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| запросов за 5 часов | 1,150 | 1,850 | 14,000 | 20,000 |
|
| запросов за 5 часов | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| запросов в неделю | 2,880 | 4,630 | 35,000 | 50,000 |
|
| запросов в неделю | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| запросов в месяц | 5,750 | 9,250 | 70,000 | 100,000 |
|
| запросов в месяц | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
Оценки основаны на наблюдаемых средних показателях запросов:
|
Оценки основаны на наблюдаемых средних показателях запросов:
|
||||||
|
|
||||||
- GLM-5 — 700 входных, 52,000 кешированных, 150 выходных токенов на запрос
|
- GLM-5 — 700 входных, 52,000 кешированных, 150 выходных токенов на запрос
|
||||||
- Kimi K2.5 — 870 входных, 55,000 кешированных, 200 выходных токенов на запрос
|
- Kimi K2.5 — 870 входных, 55,000 кешированных, 200 выходных токенов на запрос
|
||||||
- MiniMax M2.7/M2.5 — 300 входных, 55,000 кешированных, 125 выходных токенов на запрос
|
- MiniMax M2.7/M2.5 — 300 входных, 55,000 кешированных, 125 выходных токенов на запрос
|
||||||
|
- MiMo-V2-Pro — 350 входных, 41,000 кешированных, 250 выходных токенов на запрос
|
||||||
|
- MiMo-V2-Omni — 1000 входных, 60,000 кешированных, 140 выходных токенов на запрос
|
||||||
|
|
||||||
Вы можете отслеживать текущее использование в **<a href={console}>консоли</a>**.
|
Вы можете отслеживать текущее использование в **<a href={console}>консоли</a>**.
|
||||||
|
|
||||||
|
|
@ -122,6 +126,8 @@ OpenCode Go включает следующие лимиты:
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ OpenCode Go ทำงานเหมือนกับผู้ให้บร
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -74,17 +76,19 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้:
|
||||||
|
|
||||||
ตารางด้านล่างแสดงจำนวน request โดยประมาณตามรูปแบบการใช้งานปกติของ Go:
|
ตารางด้านล่างแสดงจำนวน request โดยประมาณตามรูปแบบการใช้งานปกติของ Go:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| --------------------------- | ----- | --------- | ------------ | ------------ |
|
| ---------------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| จำนวน request ต่อ 5 ชั่วโมง | 1,150 | 1,850 | 14,000 | 20,000 |
|
| requests ต่อ 5 ชั่วโมง | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| จำนวน request ต่อสัปดาห์ | 2,880 | 4,630 | 35,000 | 50,000 |
|
| requests ต่อสัปดาห์ | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| จำนวน request ต่อเดือน | 5,750 | 9,250 | 70,000 | 100,000 |
|
| requests ต่อเดือน | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
การประมาณการอ้างอิงจากรูปแบบการใช้งาน request โดยเฉลี่ยที่สังเกตพบ:
|
การประมาณการอ้างอิงจากรูปแบบการใช้งาน request โดยเฉลี่ยที่สังเกตพบ:
|
||||||
|
|
||||||
- GLM-5 — 700 input, 52,000 cached, 150 output tokens ต่อ request
|
- GLM-5 — 700 input, 52,000 cached, 150 output tokens ต่อ request
|
||||||
- Kimi K2.5 — 870 input, 55,000 cached, 200 output tokens ต่อ request
|
- Kimi K2.5 — 870 input, 55,000 cached, 200 output tokens ต่อ request
|
||||||
- MiniMax M2.7/M2.5 — 300 input, 55,000 cached, 125 output tokens ต่อ request
|
- MiniMax M2.7/M2.5 — 300 input, 55,000 cached, 125 output tokens ต่อ request
|
||||||
|
- MiMo-V2-Pro — 350 input, 41,000 cached, 250 output tokens ต่อ request
|
||||||
|
- MiMo-V2-Omni — 1000 input, 60,000 cached, 140 output tokens ต่อ request
|
||||||
|
|
||||||
คุณสามารถติดตามการใช้งานปัจจุบันของคุณได้ใน **<a href={console}>console</a>**
|
คุณสามารถติดตามการใช้งานปัจจุบันของคุณได้ใน **<a href={console}>console</a>**
|
||||||
|
|
||||||
|
|
@ -110,6 +114,8 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้:
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ Mevcut model listesi şunları içerir:
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -74,17 +76,19 @@ Limitler dolar değeri üzerinden belirlenmiştir. Bu, gerçek istek sayınızı
|
||||||
|
|
||||||
Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek sayısı sunmaktadır:
|
Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek sayısı sunmaktadır:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| ------------------ | ----- | --------- | ------------ | ------------ |
|
| ------------------ | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| 5 saatte bir istek | 1.150 | 1.850 | 14.000 | 20.000 |
|
| 5 saatte bir istek | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 |
|
||||||
| haftalık istek | 2.880 | 4.630 | 35.000 | 50.000 |
|
| haftalık istek | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 |
|
||||||
| aylık istek | 5.750 | 9.250 | 70.000 | 100.000 |
|
| aylık istek | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 |
|
||||||
|
|
||||||
Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır:
|
Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır:
|
||||||
|
|
||||||
- GLM-5 — İstek başına 700 girdi, 52.000 önbelleğe alınmış, 150 çıktı token'ı
|
- GLM-5 — İstek başına 700 girdi, 52.000 önbelleğe alınmış, 150 çıktı token'ı
|
||||||
- Kimi K2.5 — İstek başına 870 girdi, 55.000 önbelleğe alınmış, 200 çıktı token'ı
|
- Kimi K2.5 — İstek başına 870 girdi, 55.000 önbelleğe alınmış, 200 çıktı token'ı
|
||||||
- MiniMax M2.7/M2.5 — İstek başına 300 girdi, 55.000 önbelleğe alınmış, 125 çıktı token'ı
|
- MiniMax M2.7/M2.5 — İstek başına 300 girdi, 55.000 önbelleğe alınmış, 125 çıktı token'ı
|
||||||
|
- MiMo-V2-Pro — İstek başına 350 girdi, 41.000 önbelleğe alınmış, 250 çıktı token'ı
|
||||||
|
- MiMo-V2-Omni — İstek başına 1000 girdi, 60.000 önbelleğe alınmış, 140 çıktı token'ı
|
||||||
|
|
||||||
Mevcut kullanımınızı **<a href={console}>konsoldan</a>** takip edebilirsiniz.
|
Mevcut kullanımınızı **<a href={console}>konsoldan</a>** takip edebilirsiniz.
|
||||||
|
|
||||||
|
|
@ -110,6 +114,8 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ OpenCode Go 的工作方式与 OpenCode 中的其他提供商一样。
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -74,16 +76,18 @@ OpenCode Go 包含以下限制:
|
||||||
|
|
||||||
下表提供了基于典型 Go 使用模式的预估请求数:
|
下表提供了基于典型 Go 使用模式的预估请求数:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| --------------- | ----- | --------- | ------------ | ------------ |
|
| --------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| 每 5 小时请求数 | 1,150 | 1,850 | 14,000 | 20,000 |
|
| 每 5 小时请求数 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| 每周请求数 | 2,880 | 4,630 | 35,000 | 50,000 |
|
| 每周请求数 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| 每月请求数 | 5,750 | 9,250 | 70,000 | 100,000 |
|
| 每月请求数 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
预估值基于观察到的平均请求模式:
|
预估值基于观察到的平均请求模式:
|
||||||
|
|
||||||
- GLM-5 — 每次请求 700 个输入 token,52,000 个缓存 token,150 个输出 token
|
- GLM-5 — 每次请求 700 个输入 token,52,000 个缓存 token,150 个输出 token
|
||||||
- Kimi K2.5 — 每次请求 870 个输入 token,55,000 个缓存 token,200 个输出 token
|
- Kimi K2.5 — 每次请求 870 个输入 token,55,000 个缓存 token,200 个输出 token
|
||||||
|
- MiMo-V2-Pro — 每次请求 350 个输入 token,41,000 个缓存 token,250 个输出 token
|
||||||
|
- MiMo-V2-Omni — 每次请求 1000 个输入 token,60,000 个缓存 token,140 个输出 token
|
||||||
- MiniMax M2.7/M2.5 — 每次请求 300 个输入 token,55,000 个缓存 token,125 个输出 token
|
- MiniMax M2.7/M2.5 — 每次请求 300 个输入 token,55,000 个缓存 token,125 个输出 token
|
||||||
|
|
||||||
你可以在 **<a href={console}>控制台</a>** 中跟踪你当前的使用情况。
|
你可以在 **<a href={console}>控制台</a>** 中跟踪你当前的使用情况。
|
||||||
|
|
@ -110,6 +114,8 @@ OpenCode Go 包含以下限制:
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ OpenCode Go 的運作方式與 OpenCode 中的任何其他供應商相同。
|
||||||
|
|
||||||
- **GLM-5**
|
- **GLM-5**
|
||||||
- **Kimi K2.5**
|
- **Kimi K2.5**
|
||||||
|
- **MiMo-V2-Pro**
|
||||||
|
- **MiMo-V2-Omni**
|
||||||
- **MiniMax M2.5**
|
- **MiniMax M2.5**
|
||||||
- **MiniMax M2.7**
|
- **MiniMax M2.7**
|
||||||
|
|
||||||
|
|
@ -74,17 +76,19 @@ OpenCode Go 包含以下限制:
|
||||||
|
|
||||||
下表提供了基於典型 Go 使用模式的預估請求次數:
|
下表提供了基於典型 Go 使用模式的預估請求次數:
|
||||||
|
|
||||||
| | GLM-5 | Kimi K2.5 | MiniMax M2.7 | MiniMax M2.5 |
|
| | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 |
|
||||||
| --------------- | ----- | --------- | ------------ | ------------ |
|
| --------------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ |
|
||||||
| 每 5 小時請求數 | 1,150 | 1,850 | 14,000 | 20,000 |
|
| 每 5 小時請求數 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 |
|
||||||
| 每週請求數 | 2,880 | 4,630 | 35,000 | 50,000 |
|
| 每週請求數 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 |
|
||||||
| 每月請求數 | 5,750 | 9,250 | 70,000 | 100,000 |
|
| 每月請求數 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 |
|
||||||
|
|
||||||
預估值是基於觀察到的平均請求模式:
|
預估值是基於觀察到的平均請求模式:
|
||||||
|
|
||||||
- GLM-5 — 每次請求 700 個輸入 token、52,000 個快取 token、150 個輸出 token
|
- GLM-5 — 每次請求 700 個輸入 token、52,000 個快取 token、150 個輸出 token
|
||||||
- Kimi K2.5 — 每次請求 870 個輸入 token、55,000 個快取 token、200 個輸出 token
|
- Kimi K2.5 — 每次請求 870 個輸入 token、55,000 個快取 token、200 個輸出 token
|
||||||
- MiniMax M2.7/M2.5 — 每次請求 300 個輸入 token、55,000 個快取 token、125 個輸出 token
|
- MiniMax M2.7/M2.5 — 每次請求 300 個輸入 token、55,000 個快取 token、125 個輸出 token
|
||||||
|
- MiMo-V2-Pro — 每次請求 350 個輸入 token、41,000 個快取 token、250 個輸出 token
|
||||||
|
- MiMo-V2-Omni — 每次請求 1000 個輸入 token、60,000 個快取 token、140 個輸出 token
|
||||||
|
|
||||||
您可以在 **<a href={console}>console</a>** 中追蹤您目前的使用量。
|
您可以在 **<a href={console}>console</a>** 中追蹤您目前的使用量。
|
||||||
|
|
||||||
|
|
@ -110,10 +114,12 @@ OpenCode Go 包含以下限制:
|
||||||
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
| ------------ | ------------ | ------------------------------------------------ | --------------------------- |
|
||||||
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| GLM-5 | glm-5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
|
| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
|
|
||||||
您的 OpenCode 設定中的 [模型 ID](/docs/config/#models) 使用 `opencode-go/<model-id>` 的格式。例如,對於 Kimi K2.5,您應該在您的設定中使用 `opencode-go/kimi-k2.5`。
|
您的 OpenCode 設定中的 [model id](/docs/config/#models) 使用 `opencode-go/<model-id>` 格式。例如,Kimi K2.5 在設定中應使用 `opencode-go/kimi-k2.5`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue