mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Compare commits
327
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91c13b306c | ||
|
|
001473b7a8 | ||
|
|
415445ffbc | ||
|
|
0e0b79b8c5 | ||
|
|
0d9792e8b0 | ||
|
|
b33fe75d7e | ||
|
|
d1dddb53f3 | ||
|
|
d2a8d0b190 | ||
|
|
efd0a4430a | ||
|
|
b6f7e2e8e8 | ||
|
|
81d1c2e709 | ||
|
|
6271a7e264 | ||
|
|
afb73be003 | ||
|
|
694d4d79c8 | ||
|
|
77990ed9eb | ||
|
|
f4766d1369 | ||
|
|
7358ceb085 | ||
|
|
d65ae94a50 | ||
|
|
54ef19b12a | ||
|
|
ca96e644b4 | ||
|
|
5d7a599ffd | ||
|
|
c0d4b5a582 | ||
|
|
a2deb8cf51 | ||
|
|
41f3c1e6dd | ||
|
|
ead1e05b4e | ||
|
|
c2b55b9824 | ||
|
|
faf26a3c81 | ||
|
|
6b9c38c2a0 | ||
|
|
790fdfca4b | ||
|
|
7a8dfa5499 | ||
|
|
4e76141a41 | ||
|
|
921e7bdb95 | ||
|
|
77a30b1c9e | ||
|
|
6565ce0c85 | ||
|
|
7a70fa4bf8 | ||
|
|
86eeae3d6d | ||
|
|
932736c19e | ||
|
|
7c2890aeff | ||
|
|
a5984a25bb | ||
|
|
b66b2c34ff | ||
|
|
51163562f1 | ||
|
|
6749731ea3 | ||
|
|
1ace88e63d | ||
|
|
db0c478900 | ||
|
|
18043ab882 | ||
|
|
9c435af496 | ||
|
|
7c03770b1a | ||
|
|
78c884b1f2 | ||
|
|
0f48be263d | ||
|
|
0bced53fe4 | ||
|
|
b52bb7d4c3 | ||
|
|
b69954c912 | ||
|
|
754dee6725 | ||
|
|
d03684b080 | ||
|
|
b0578fca34 | ||
|
|
8f9c7806c6 | ||
|
|
23f44c5171 | ||
|
|
28383e9f92 | ||
|
|
0a4d0493ee | ||
|
|
765bdfc872 | ||
|
|
13247c20e3 | ||
|
|
b30ff93cac | ||
|
|
e309ee2200 | ||
|
|
782070a0df | ||
|
|
9a4d3dfaa7 | ||
|
|
852b5e65aa | ||
|
|
9393e594f2 | ||
|
|
871687fee7 | ||
|
|
b12683f48b | ||
|
|
88ca5ae82b | ||
|
|
a6f744d414 | ||
|
|
580339980f | ||
|
|
daec47c72a | ||
|
|
1eb7516562 | ||
|
|
f392dd2fa7 | ||
|
|
c0a7ebe10d | ||
|
|
63da41c794 | ||
|
|
1651a909f0 | ||
|
|
8ea3bec3e2 | ||
|
|
eba925da41 | ||
|
|
d85fe68a17 | ||
|
|
73aca5b612 | ||
|
|
195ee6dcf3 | ||
|
|
a7e6771ae5 | ||
|
|
0c88b9d0b4 | ||
|
|
e8ac15d52a | ||
|
|
e4dd0ad1b4 | ||
|
|
404af94b9e | ||
|
|
8de4c91010 | ||
|
|
b94c4aa944 | ||
|
|
ef1a339ca9 | ||
|
|
6d679edf67 | ||
|
|
29c033cfd5 | ||
|
|
3eda0b98e4 | ||
|
|
b5485b1649 | ||
|
|
5e1ea240fb | ||
|
|
c046c5281f | ||
|
|
7ad19b82a5 | ||
|
|
7b34dd738d | ||
|
|
d77138966a | ||
|
|
4642a4cbee | ||
|
|
2df63d9f9a | ||
|
|
99e466f2cc | ||
|
|
c145598d47 | ||
|
|
5ff8944e9f | ||
|
|
e080b2b972 | ||
|
|
614f9d3831 | ||
|
|
9d53a3aad7 | ||
|
|
8d306bd0e6 | ||
|
|
a9cae776f5 | ||
|
|
13c1820de5 | ||
|
|
5d33e047f4 | ||
|
|
ce860db141 | ||
|
|
688658585b | ||
|
|
eb6ba928be | ||
|
|
ac7836f048 | ||
|
|
ac7d7be97d | ||
|
|
a2e20b8a2f | ||
|
|
0be8aaf783 | ||
|
|
1c12f7c174 | ||
|
|
07b2453403 | ||
|
|
b619b3dbfe | ||
|
|
4cdd4bde93 | ||
|
|
8ac91c3399 | ||
|
|
9fc7f5022c | ||
|
|
7d6687d03b | ||
|
|
af710d8784 | ||
|
|
723b2b8ef2 | ||
|
|
3afb4dea49 | ||
|
|
79b0547997 | ||
|
|
e6d0d4aebe | ||
|
|
7b7e09fdcd | ||
|
|
631bd6b111 | ||
|
|
7ac760e44d | ||
|
|
9dcfdf2e7c | ||
|
|
f86eb89b74 | ||
|
|
6292d66e90 | ||
|
|
3bb9dce90a | ||
|
|
5d3a70950c | ||
|
|
a27298c462 | ||
|
|
f84126a76f | ||
|
|
e8c7feeeef | ||
|
|
f836dc6a3a | ||
|
|
69cb060066 | ||
|
|
6328dfa0b5 | ||
|
|
9bb0aa28fd | ||
|
|
3a754cebdb | ||
|
|
6694a1b81a | ||
|
|
003000be21 | ||
|
|
92a1677fda | ||
|
|
e4f1f80a76 | ||
|
|
9ced3d1577 | ||
|
|
5caa03eca1 | ||
|
|
309ea9aa0f | ||
|
|
15155c3a67 | ||
|
|
7f7a063139 | ||
|
|
8c608c8bba | ||
|
|
e9a863c36d | ||
|
|
0d30c68641 | ||
|
|
dc8f746f5e | ||
|
|
f6d948c60b | ||
|
|
b2f36909eb | ||
|
|
b5f2f0c025 | ||
|
|
c9daeddf3e | ||
|
|
8dd9294495 | ||
|
|
f2dc940a29 | ||
|
|
9d930f67f4 | ||
|
|
c0766fbe59 | ||
|
|
73e2fb91af | ||
|
|
4df81041b1 | ||
|
|
4bcda5691d | ||
|
|
383e6369ac | ||
|
|
6bb625fcf7 | ||
|
|
8d963086ea | ||
|
|
85df91d4ef | ||
|
|
af2dbb252d | ||
|
|
8de4b4851c | ||
|
|
fb3d9d7433 | ||
|
|
66029c7dec | ||
|
|
43b210573b | ||
|
|
422b47b3a0 | ||
|
|
d80c290d89 | ||
|
|
594cbf664a | ||
|
|
7bb8031e85 | ||
|
|
188df0245b | ||
|
|
c2df8e3ed6 | ||
|
|
104d01ba07 | ||
|
|
e977923203 | ||
|
|
5450ed38e9 | ||
|
|
3fcce85ca7 | ||
|
|
d6cc51fe76 | ||
|
|
abca1a62c5 | ||
|
|
b70b86e7a7 | ||
|
|
754166fc32 | ||
|
|
0aeb204660 | ||
|
|
1fa7030ced | ||
|
|
43661cdec1 | ||
|
|
e07bc5faa8 | ||
|
|
a968fd8881 | ||
|
|
420a9231c5 | ||
|
|
11b84e5891 | ||
|
|
0495d1f294 | ||
|
|
0040dec2a0 | ||
|
|
4b94a02c27 | ||
|
|
28a9010d0e | ||
|
|
71fb27901b | ||
|
|
223c55b95f | ||
|
|
d84543d259 | ||
|
|
0247623274 | ||
|
|
13f238e6f4 | ||
|
|
0209b345a0 | ||
|
|
8fa67e544e | ||
|
|
fe850bc160 | ||
|
|
f527fa023d | ||
|
|
d98ae85fb0 | ||
|
|
687186fcdd | ||
|
|
d30fd833d9 | ||
|
|
459c2ae5c4 | ||
|
|
33199377da | ||
|
|
1f1c469b16 | ||
|
|
7fe57fc25a | ||
|
|
6cc249f1d4 | ||
|
|
e5a801c603 | ||
|
|
fe5ea48568 | ||
|
|
3aa259f2e2 | ||
|
|
ed9c424991 | ||
|
|
9328ef003a | ||
|
|
824f95613a | ||
|
|
03d5346869 | ||
|
|
490f3d0bfe | ||
|
|
64c8aa5170 | ||
|
|
00d9c2e490 | ||
|
|
ff1163da75 | ||
|
|
6cd9593404 | ||
|
|
b3d7dd50fc | ||
|
|
f8e541a86b | ||
|
|
b5ea839c94 | ||
|
|
4c8eda7344 | ||
|
|
dfd8aac901 | ||
|
|
f69e2c5798 | ||
|
|
1918bb4f55 | ||
|
|
53d21288df | ||
|
|
3c0c265c22 | ||
|
|
e179b51c52 | ||
|
|
28b6680353 | ||
|
|
788d2b8d41 | ||
|
|
f5e0d83f0d | ||
|
|
3b21d25de8 | ||
|
|
c3b51178cb | ||
|
|
18df489e23 | ||
|
|
5598442a99 | ||
|
|
3b8bb5492b | ||
|
|
1718074787 | ||
|
|
bb013c4bc9 | ||
|
|
1cbd447539 | ||
|
|
5b609815c5 | ||
|
|
0102bf3a6a | ||
|
|
873990724d | ||
|
|
9d950f34bc | ||
|
|
e9882d667e | ||
|
|
0af416ce89 | ||
|
|
ee625f5e41 | ||
|
|
d5a5a6e70f | ||
|
|
53b82d1362 | ||
|
|
5c78af6b09 | ||
|
|
dfbe776afb | ||
|
|
0056f8386b | ||
|
|
bf8210aebf | ||
|
|
88966f614f | ||
|
|
46542dd558 | ||
|
|
4376074c23 | ||
|
|
ff6f93280e | ||
|
|
651d2bd4df | ||
|
|
d6eed9aee4 | ||
|
|
695802235e | ||
|
|
5d786d6de0 | ||
|
|
01a52ce15e | ||
|
|
ad99a35714 | ||
|
|
74318f9d49 | ||
|
|
a237c5843e | ||
|
|
fa85a69b9d | ||
|
|
1320621cbe | ||
|
|
c116fd76ec | ||
|
|
e3ae0e78ab | ||
|
|
6d9266fc8e | ||
|
|
d2a86112a4 | ||
|
|
d08999485a | ||
|
|
42ef9dd191 | ||
|
|
cf2146408a | ||
|
|
226984ba69 | ||
|
|
f309fed993 | ||
|
|
6db27d94ae | ||
|
|
f72102c8a6 | ||
|
|
7733545274 | ||
|
|
fa39e7db7f | ||
|
|
21b3b35210 | ||
|
|
714c4dc935 | ||
|
|
a2a429e3aa | ||
|
|
85810c192d | ||
|
|
5e2985ab6f | ||
|
|
6f5c150339 | ||
|
|
46c784ba5e | ||
|
|
fb3a57133b | ||
|
|
b5b91b1c71 | ||
|
|
7db17aff95 | ||
|
|
e29dad555f | ||
|
|
539f2368e9 | ||
|
|
eb4641d630 | ||
|
|
e51f8f59f6 | ||
|
|
c6aae2e730 | ||
|
|
e1f30114d2 | ||
|
|
cbcc9551f9 | ||
|
|
83856b0f00 | ||
|
|
08c717c2fa | ||
|
|
e0b45d186a | ||
|
|
2fcb221fad | ||
|
|
a32c4a4c3e | ||
|
|
a03391a13f | ||
|
|
23ade961ba | ||
|
|
d8ad8bdc3f | ||
|
|
e47a2037f2 | ||
|
|
092a0272cd | ||
|
|
f151f5ba9a | ||
|
|
0019ad31d0 | ||
|
|
205f4ef09f | ||
|
|
20354e388f | ||
|
|
4111108fc4 |
No files matched your search
@@ -6,3 +6,9 @@ contact_links:
|
||||
- name: Questions & discussions
|
||||
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
|
||||
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
|
||||
- name: Discord
|
||||
url: https://discord.gg/XF78rEME2D
|
||||
about: Bilingual community chat for setup help, workflow showcases, and feature ideas.
|
||||
- name: QQ group (DeepSeek-Reasonix官方群, 1093562660)
|
||||
url: https://qm.qq.com/q/i59b0z2R8s
|
||||
about: Chinese-language community chat for setup help and everyday questions.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
+11
-11
@@ -61,8 +61,8 @@ jobs:
|
||||
# its DTOs are generated from internal/ext/extension/protocol, so
|
||||
# both paths must trigger the sdk job.
|
||||
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
|
||||
# The release-note tests render every committed Studio note.
|
||||
if echo "$files" | grep -qE '^(scripts/|release-notes/studio/)'; then scripts=true; fi
|
||||
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
@@ -175,12 +175,12 @@ jobs:
|
||||
# budget above normal 7-9 minute runner variance; each package test binary
|
||||
# remains independently bounded by Go's per-package timeout below.
|
||||
#
|
||||
# That timeout is 8m, not 3m: across four consecutive runs of the same
|
||||
# That timeout is 12m, not 3m: across four consecutive runs of the same
|
||||
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
|
||||
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
|
||||
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
|
||||
# step failed on slow runners without a code change; a hang still trips
|
||||
# the 15 minute job budget above.
|
||||
# variance alone, with four heavy packages sharing four vCPUs. The boot package
|
||||
# has since grown to 422s and then ran past 480s on two consecutive runs of the
|
||||
# same code, so 8m no longer leaves room. A hang still trips the job budget.
|
||||
#
|
||||
# Every root-module `go test` that runs on Windows passes -count=1. With
|
||||
# the result cache on, go test records each file and environment access
|
||||
@@ -191,11 +191,11 @@ jobs:
|
||||
# internal/base/testenv holds every such step to it.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=8m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
run: go test -count=1 -p 4 -timeout=12m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/contract/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
|
||||
|
||||
# Full push suite still uses the same 8m package budget as Windows smoke:
|
||||
# Full push suite still uses the same 12m package budget as Windows smoke:
|
||||
# agent/boot/control already hit 180s package timeouts under runner load,
|
||||
# so 3m fails on slow machines without a code regression. It runs with
|
||||
# -count=1 for the reason given at the smoke step. A hang still dies here
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=8m ./...
|
||||
run: go test -count=1 -p 4 -timeout=12m ./...
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Sync in-app feedback
|
||||
|
||||
# Turns feedback submitted from Reasonix Studio into issues and reports issue
|
||||
# state (in progress, fixed in a tag, won't fix, duplicate) back to the worker,
|
||||
# and mirrors the reporter's replies onto their issue as bot comments.
|
||||
# Text from the public reaches the script only through the worker's JSON and
|
||||
# the GitHub API, never through a shell command line or an expression.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
- cron: '5 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print what would happen without creating issues or writing back
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
full:
|
||||
description: Re-read every open entry instead of only recently changed issues
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: feedback-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Check secret
|
||||
id: gate
|
||||
env:
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$FEEDBACK_ADMIN_TOKEN" ]; then
|
||||
echo "::notice::FEEDBACK_ADMIN_TOKEN is not set; feedback sync skipped"
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# git tag --contains needs full history; the tag API would cost a request per entry.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Sync
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
# crash.reasonix.io answers runner IPs with a managed challenge; workers.dev serves only the converter's admin paths.
|
||||
FEEDBACK_BASE_URL: https://reasonix-crash-report.reasonix.workers.dev
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
FULL: ${{ github.event.inputs.full || 'false' }}
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
|
||||
# The daily schedule sweeps everything the change window could have missed.
|
||||
if [ "$FULL" = "true" ] || [ "$SCHEDULE" = "5 3 * * *" ]; then args+=(--full); fi
|
||||
node scripts/feedback-sync.mjs "${args[@]}"
|
||||
@@ -1107,6 +1107,108 @@ jobs:
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog -> $VERSION"
|
||||
|
||||
# A CLI channel that moves while 1.x still publishes would have two lines
|
||||
# writing it, so every CLI job waits for this check. It runs no repository code.
|
||||
cli-gate:
|
||||
name: require the 1.x line to be frozen
|
||||
needs: resolve
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Require CLI_PUBLISH_FROZEN
|
||||
env:
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
run: |
|
||||
if [ "$CLI_PUBLISH_FROZEN" != true ]; then
|
||||
echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The v<version> tag the update pointer needs is created here, by the release
|
||||
# identity the 1.x line also uses, because only that identity may create v*
|
||||
# tags. This job checks out nothing and runs no repository code; the one step
|
||||
# that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio
|
||||
# tag or branch, through the studio-release environment's ref policy.
|
||||
cli-tag:
|
||||
name: create the CLI tag
|
||||
needs: [resolve, publish, cli-gate]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Create v<version> on the studio commit
|
||||
env:
|
||||
TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
|
||||
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CLI_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=esengine/DeepSeek-Reasonix
|
||||
stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
|
||||
tag="v${CLI_VERSION#v}"
|
||||
if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then
|
||||
echo "$tag publishes no CLI pointer; no tag is created"
|
||||
exit 0
|
||||
fi
|
||||
[ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; }
|
||||
[ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; }
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/studio-v* | refs/heads/studio) ;;
|
||||
*) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;;
|
||||
esac
|
||||
[[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; }
|
||||
[[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; }
|
||||
[ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; }
|
||||
[[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; }
|
||||
read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; }
|
||||
write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; }
|
||||
actor="$(write_api user)"
|
||||
jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null ||
|
||||
{ echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; }
|
||||
[ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] ||
|
||||
{ echo "::error::the release tag identity cannot push to $repo"; exit 1; }
|
||||
[ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] ||
|
||||
{ echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; }
|
||||
case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in
|
||||
behind | identical) ;;
|
||||
*) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;;
|
||||
esac
|
||||
# Prints the commit the tag points at; status 1 only when the tag does not exist.
|
||||
tag_commit() {
|
||||
local ref kind sha
|
||||
ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || {
|
||||
grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1
|
||||
cat "$RUNNER_TEMP/tag.error" >&2
|
||||
exit 1
|
||||
}
|
||||
read -r kind sha <<<"$ref"
|
||||
[ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)"
|
||||
echo "$sha"
|
||||
}
|
||||
if existing="$(tag_commit)"; then
|
||||
[ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; }
|
||||
echo "$tag already points at $APPROVED_SHA"
|
||||
exit 0
|
||||
fi
|
||||
if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then
|
||||
# A concurrent run may have created the same tag in the meantime.
|
||||
if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then
|
||||
echo "$tag already points at $APPROVED_SHA"
|
||||
exit 0
|
||||
fi
|
||||
cat "$RUNNER_TEMP/create.error" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "created $tag at $APPROVED_SHA"
|
||||
|
||||
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
|
||||
# line owns them until this one takes the CLI over; the repository variable
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
|
||||
@@ -1115,11 +1217,14 @@ jobs:
|
||||
# candidate cask would become the default install.
|
||||
cli-channels:
|
||||
name: publish the CLI to npm and Homebrew
|
||||
needs: [resolve, publish]
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# id-token lets npm publish --provenance attach a Sigstore attestation; the
|
||||
# registry token still authenticates the publish itself.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -1160,3 +1265,47 @@ jobs:
|
||||
fi
|
||||
git -c user.name=reasonix -c [email protected] commit -m "reasonix ${VERSION#v}"
|
||||
git push origin HEAD:main
|
||||
|
||||
# The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets
|
||||
# sit under that tag, so the archives go to a v<version> release. Only a
|
||||
# -preview.N version moves preview; other prereleases publish nothing.
|
||||
cli-pointer:
|
||||
name: publish the CLI update pointer
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write # create the CLI release; cli-tag has already made its tag
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: cli-archives
|
||||
path: cli-archives
|
||||
- name: Publish the release, its record and the pointer
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLI_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
ARCHIVES_DIR: cli-archives
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "::error::R2 credentials are not configured; the CLI pointer was not written"
|
||||
exit 1
|
||||
fi
|
||||
bash scripts/publish-cli-pointer.sh
|
||||
@@ -18,6 +18,8 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,6 +33,7 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -41,12 +44,16 @@
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
|
||||
<h3 align="center">A coding agent you can leave running.</h3>
|
||||
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
|
||||
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
|
||||
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
|
||||
|
||||
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -265,8 +272,9 @@ coffee, not a contract — donations don't buy feature priority or change how
|
||||
issues get triaged.
|
||||
|
||||
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
- **国内** — 微信支付(扫码)
|
||||
- **国内** — 微信支付 / 支付宝(扫码)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
|
||||
</p>
|
||||
+10
-2
@@ -18,6 +18,8 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,6 +33,7 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -41,12 +44,16 @@
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
|
||||
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
|
||||
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
|
||||
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
|
||||
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
|
||||
|
||||
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
|
||||
|
||||
## 版本
|
||||
|
||||
@@ -245,9 +252,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
|
||||
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
|
||||
|
||||
- **国内** — 微信支付(扫下方二维码)
|
||||
- **国内** — 微信支付 / 支付宝(扫下方二维码)
|
||||
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
|
||||
</p>
|
||||
@@ -3,12 +3,12 @@
|
||||
Two questions about long-horizon memory, and the harness that makes their
|
||||
answers trustworthy.
|
||||
|
||||
-mode=preflight build all 12 tasks under every arm and check the contract
|
||||
-mode=preflight build all 18 tasks under every arm and check the contract
|
||||
-mode=snippet does a rank-1 hit hand over the whole answer?
|
||||
-mode=adversarial send a model after the answer through every host surface
|
||||
-mode=run-search Experiment R: is searchable canonical a real capability?
|
||||
-mode=run-boundary Experiment I, paired: 6 tasks across their own cue boundary
|
||||
-mode=run-index Experiment I, full: 6 tasks x 4 index budgets
|
||||
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
|
||||
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
|
||||
-mode=calibrate find each index task's PlantAfterGen for its cue tier
|
||||
|
||||
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
|
||||
@@ -31,8 +31,8 @@ The most likely reason is that two of the six index tasks carry heavy-tail
|
||||
stopping behaviour that swamps the effect being measured.
|
||||
|
||||
Existing evidence is not sufficient to tune the shipped 1% budget in either
|
||||
direction. Reopening this wants a new corpus, not more samples: see
|
||||
"Index corpus v2" below.
|
||||
direction. Reopening this wants a new corpus, not more samples: that substrate
|
||||
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
|
||||
|
||||
**Things that turned out not to be problems**, each after being measured rather
|
||||
than argued about:
|
||||
@@ -74,11 +74,83 @@ scanned for answer literals before the first provider request and again after
|
||||
the turn. Any answer reaching the model through a tool that is not `recall`
|
||||
marks the run contaminated and takes it out of every statistic.
|
||||
|
||||
## Index corpus v2, if this is reopened
|
||||
## Index corpus v2
|
||||
|
||||
The present index tasks are not a fit substrate: two of six carry stopping
|
||||
behaviour that dominates the signal. A task should qualify only if a run-index
|
||||
run reports a `stopping_class` other than `PostSufficientRetrieval` or
|
||||
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
|
||||
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
|
||||
`i47-tenantlease` and `i112-lagquota` (default).
|
||||
|
||||
They came out of five batches of candidates, each screened the same way: one
|
||||
`-mode=run-index` pass over the batch, then two more only for the candidates that
|
||||
were clean on both boundary arms, and only the candidates that held on both arms
|
||||
in all three passes stayed. A candidate that does not hold is dropped, not
|
||||
promoted. The numbers are candidates, per tier:
|
||||
|
||||
| batch | quarter | half | default |
|
||||
| --- | --- | --- | --- |
|
||||
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
|
||||
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
|
||||
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
|
||||
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
|
||||
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
|
||||
|
||||
That table counts candidates that held on both arms in all three passes, which is
|
||||
not the size of the substrate. 89 candidates were authored, 77 were screened (the
|
||||
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
|
||||
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
|
||||
the other 19 survivors are spares rather than corpus members. Candidates from the
|
||||
earlier round are not counted here.
|
||||
|
||||
| task | tier | cue present | cue absent |
|
||||
| --- | --- | ---: | ---: |
|
||||
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
|
||||
| `i118-fencelag` | quarter | 3/3 | 3/3 |
|
||||
| `i106-budgetseal` | half | 3/3 | 3/3 |
|
||||
| `i70-ingestfloor` | half | 3/3 | 3/3 |
|
||||
| `i47-tenantlease` | default | 3/3 | 3/3 |
|
||||
| `i112-lagquota` | default | 3/3 | 3/3 |
|
||||
|
||||
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
|
||||
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
|
||||
`NeverSufficient`.
|
||||
|
||||
Four of the first six were flagged in review for sharing answer values, probe
|
||||
queries and prompt wording within a tier. Separating them and re-screening those
|
||||
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
|
||||
were replaced - `i90-recoverylag` was tried first and did not hold either, and
|
||||
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
|
||||
cosmetic: the shared wording had been carrying two of the six.
|
||||
|
||||
Nothing in the task shape separates the survivors from the rest: within the half
|
||||
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
|
||||
second, and codename answers survived 8 of 16 in the third. An earlier claim here
|
||||
that the answer kind decides whether a candidate holds does not survive this
|
||||
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
|
||||
failures - the model re-checking a value it already had - but which tasks hit it
|
||||
was not predictable from their shape at this sample.
|
||||
|
||||
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
|
||||
once each with `-mode=run-index -task <id>`, per task and per arm:
|
||||
|
||||
| task | tier | cue present | cue absent |
|
||||
| --- | --- | --- | --- |
|
||||
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
|
||||
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
|
||||
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
|
||||
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
|
||||
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
|
||||
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
|
||||
|
||||
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
|
||||
and `i01` is clean on both. The cue-present arm is clean for four of them
|
||||
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
|
||||
stopping side rather than being retitled. All six are excluded from `run-index`
|
||||
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
|
||||
task added to the corpus declares a role, so it cannot arrive without one.
|
||||
|
||||
A task qualifies only if it is clean on both of its boundary arms - the
|
||||
cue-present run and the cue-absent run - in every run of a screening batch. Clean
|
||||
means a `stopping_class` other than `PostSufficientRetrieval` or
|
||||
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
|
||||
all qualify, and a defensive read is deliberately not counted as waste. The
|
||||
class comes from the three counts in that report — `searches_after_sufficient`,
|
||||
@@ -86,8 +158,7 @@ class comes from the three counts in that report — `searches_after_sufficient`
|
||||
sufficiency was reached and how many rounds followed it. The remaining
|
||||
requirements still apply — successful tool call, answer in the output rather
|
||||
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
|
||||
nothing in the workspace. Keep i03 and i04 — as a stopping corpus, not an
|
||||
efficiency one.
|
||||
nothing in the workspace.
|
||||
|
||||
`-mode=calibrate` is not a screening tool: it builds and reads back the
|
||||
fixtures, and reads nothing else, so its cue-visibility profile is the same for
|
||||
|
||||
@@ -26,6 +26,12 @@ const (
|
||||
tierDefault = "default" // visible at default only
|
||||
)
|
||||
|
||||
// role places an index task in the efficiency substrate or the stopping corpus.
|
||||
const (
|
||||
roleEfficiency = "efficiency"
|
||||
roleStopping = "stopping"
|
||||
)
|
||||
|
||||
// plantKind is how a task's target enters the transcript.
|
||||
const (
|
||||
plantAssistant = "assistant" // the model's own account: never index-addressed
|
||||
@@ -45,6 +51,8 @@ type contextTask struct {
|
||||
AnswerVars []string
|
||||
CueVar string
|
||||
CueTier string
|
||||
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
|
||||
Role string
|
||||
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
|
||||
// so what follows a cue decides which budget addresses it. Frozen, because
|
||||
// a run that searched for it would let the tested code move its goalposts.
|
||||
@@ -121,6 +129,20 @@ func num(name string, lo, hi int) varSpec {
|
||||
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
|
||||
}
|
||||
|
||||
// efficiencyTasks is the substrate a study measures on. Every task in it
|
||||
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
|
||||
// because a task carrying post-sufficient stopping behaviour swamps the effect
|
||||
// an efficiency batch exists to measure.
|
||||
func efficiencyTasks() []contextTask {
|
||||
var out []contextTask
|
||||
for _, t := range indexTasks() {
|
||||
if t.Role == roleEfficiency {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func searchTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
@@ -192,10 +214,104 @@ func searchTasks() []contextTask {
|
||||
}
|
||||
}
|
||||
|
||||
func indexTasks() []contextTask {
|
||||
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
|
||||
func efficiencyIndexTasks() []contextTask {
|
||||
out := indexQuarterTasks()
|
||||
out = append(out, indexHalfTasks()...)
|
||||
out = append(out, indexDefaultTasks()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// indexQuarterTasks holds the quarter-tier literals.
|
||||
func indexQuarterTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
|
||||
ProbeQuery: "dispatchlag dispatch lag epoch",
|
||||
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
|
||||
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "p37", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
|
||||
PlantBody: "dispatch lag epoch = {{epoch}}",
|
||||
},
|
||||
{
|
||||
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
|
||||
ProbeQuery: "fencelag fence lag",
|
||||
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
|
||||
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "x118", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
|
||||
PlantBody: "fence lag = {{v}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexHalfTasks holds the half-tier literals.
|
||||
func indexHalfTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
|
||||
ProbeQuery: "ingestfloor quota",
|
||||
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
|
||||
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "q70", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
|
||||
PlantBody: "route quota = {{quota}}",
|
||||
},
|
||||
{
|
||||
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
|
||||
ProbeQuery: "budgetseal route quota",
|
||||
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
|
||||
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "h106", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
|
||||
PlantBody: "route quota = {{quota}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexDefaultTasks holds the default-tier literals.
|
||||
func indexDefaultTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
|
||||
ProbeQuery: "tenantlease tenant lease",
|
||||
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
|
||||
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "p47", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
|
||||
PlantBody: "tenant lease = {{lease}}",
|
||||
},
|
||||
{
|
||||
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
|
||||
ProbeQuery: "lagquota lagquota lease",
|
||||
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
|
||||
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "d112", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
|
||||
PlantBody: "lagquota lease = {{lease}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
|
||||
// batches and measurable on their own with -task <id>.
|
||||
func stoppingIndexTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
|
||||
ProbeQuery: "transport fallback probe selector",
|
||||
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
|
||||
@@ -206,7 +322,7 @@ func indexTasks() []contextTask {
|
||||
PlantBody: "fallback selector = {{selector}}",
|
||||
},
|
||||
{
|
||||
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
|
||||
ProbeQuery: "scheduler handoff grace marker",
|
||||
Vars: []varSpec{
|
||||
@@ -220,7 +336,7 @@ func indexTasks() []contextTask {
|
||||
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
|
||||
},
|
||||
{
|
||||
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
|
||||
ProbeQuery: "coalescing policy completion window",
|
||||
Vars: []varSpec{
|
||||
@@ -234,7 +350,7 @@ func indexTasks() []contextTask {
|
||||
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
|
||||
},
|
||||
{
|
||||
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
|
||||
ProbeQuery: "recovery fence epoch check",
|
||||
Vars: []varSpec{
|
||||
@@ -248,7 +364,7 @@ func indexTasks() []contextTask {
|
||||
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
|
||||
},
|
||||
{
|
||||
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
|
||||
ProbeQuery: "cache probe prefix salt scope",
|
||||
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
|
||||
@@ -259,7 +375,7 @@ func indexTasks() []contextTask {
|
||||
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
|
||||
},
|
||||
{
|
||||
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
|
||||
ProbeQuery: "dispatch handoff probe mode",
|
||||
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
|
||||
@@ -272,6 +388,12 @@ func indexTasks() []contextTask {
|
||||
}
|
||||
}
|
||||
|
||||
// indexTasks is the whole index corpus, the efficiency substrate first.
|
||||
func indexTasks() []contextTask {
|
||||
out := efficiencyIndexTasks()
|
||||
return append(out, stoppingIndexTasks()...)
|
||||
}
|
||||
|
||||
func allTasks() []contextTask {
|
||||
return append(searchTasks(), indexTasks()...)
|
||||
}
|
||||
@@ -332,6 +454,9 @@ func validateCorpus() error {
|
||||
if t.CueVar == "" {
|
||||
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
|
||||
}
|
||||
if t.Role != roleEfficiency && t.Role != roleStopping {
|
||||
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
|
||||
}
|
||||
if v, _ := tierScales(t.CueTier); v == nil {
|
||||
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,14 @@ func main() {
|
||||
case "adversarial":
|
||||
os.Exit(runAdversarial(root))
|
||||
case "run-index":
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, tasks))
|
||||
batch := tasks
|
||||
if *only == "" {
|
||||
// A batch measures the efficiency substrate only. Naming one task
|
||||
// with -task runs that task, role included, so the stopping corpus
|
||||
// stays measurable on its own.
|
||||
batch = efficiencyTasks()
|
||||
}
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
|
||||
os.Exit(2)
|
||||
|
||||
@@ -19,16 +19,24 @@ func TestCorpusIsWellFormed(t *testing.T) {
|
||||
if got := len(searchTasks()); got != 6 {
|
||||
t.Errorf("search experiment has %d tasks, want 6", got)
|
||||
}
|
||||
if got := len(indexTasks()); got != 6 {
|
||||
t.Errorf("index experiment has %d tasks, want 6", got)
|
||||
if got := len(indexTasks()); got != 12 {
|
||||
t.Errorf("index experiment has %d tasks, want 12", got)
|
||||
}
|
||||
stopping := map[string]bool{}
|
||||
tiers := map[string]int{}
|
||||
for _, task := range indexTasks() {
|
||||
if task.Role == roleStopping {
|
||||
stopping[task.ID] = true
|
||||
continue
|
||||
}
|
||||
tiers[task.CueTier]++
|
||||
}
|
||||
// Two per tier is what makes the staircase readable: the same task can be
|
||||
// compared across the boundary its cue sits on, instead of two different
|
||||
// questions being compared across two arms.
|
||||
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
|
||||
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
|
||||
}
|
||||
// Two per tier, over the efficiency substrate, is what makes the staircase
|
||||
// readable: the same task can be compared across the boundary its cue sits
|
||||
// on, instead of two different questions being compared across two arms.
|
||||
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
|
||||
if tiers[tier] != 2 {
|
||||
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
|
||||
@@ -36,6 +44,21 @@ func TestCorpusIsWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The role decides which batch a task enters; the literal lists are only where
|
||||
// it is declared, so the two must not drift.
|
||||
func TestIndexListsMatchTheirRoles(t *testing.T) {
|
||||
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
|
||||
if task.Role != roleEfficiency {
|
||||
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
|
||||
}
|
||||
}
|
||||
for _, task := range stoppingIndexTasks() {
|
||||
if task.Role != roleStopping {
|
||||
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant the whole instantiation layer exists for: no literal a scorer
|
||||
// needs may be readable in this repository before a run starts.
|
||||
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
|
||||
|
||||
@@ -232,7 +232,7 @@ func runBoundaries(root string) int {
|
||||
|
||||
var all []contextMetrics
|
||||
var cueSide, noCueSide []contextMetrics
|
||||
for _, t := range indexTasks() {
|
||||
for _, t := range efficiencyTasks() {
|
||||
cue, noCue := boundaryPair(t.CueTier)
|
||||
for _, side := range []struct {
|
||||
scale string
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/frontend/serve"
|
||||
)
|
||||
|
||||
// launchWorkspace is the folder the first pane opens. The working directory
|
||||
// counts only when someone chose it: a Start-menu or Finder launch inherits the
|
||||
// install directory or the filesystem root, and taking that as a project puts
|
||||
// the app's own folder in the sidebar again after every removal.
|
||||
func launchWorkspace(appExe string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
|
||||
}
|
||||
|
||||
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
|
||||
if userChoseDir(cwd, appExe) {
|
||||
return cwd
|
||||
}
|
||||
for _, dir := range remembered {
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
if home != "" {
|
||||
return home
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func userChoseDir(cwd, appExe string) bool {
|
||||
if cwd == "" || filepath.Dir(cwd) == cwd {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Dir(appExe), cwd)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
inside := rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
return !inside
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
cases := []struct {
|
||||
name, cwd, exe string
|
||||
want bool
|
||||
}{
|
||||
{"install directory", app, exe, false},
|
||||
{"below the install directory", filepath.Join(app, "resources"), exe, false},
|
||||
{"filesystem root", filepath.VolumeName(base) + string(filepath.Separator), exe, false},
|
||||
{"a project elsewhere", filepath.Join(base, "project"), exe, true},
|
||||
{"a sibling sharing the name prefix", app + "-notes", exe, true},
|
||||
{"unpackaged run keeps the directory", app, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := userChoseDir(c.cwd, c.exe); got != c.want {
|
||||
t.Errorf("%s: userChoseDir(%q) = %v, want %v", c.name, c.cwd, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
mk := func(name string) string {
|
||||
d := filepath.Join(base, name)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
a, b, home := mk("a"), mk("b"), mk("home")
|
||||
gone := filepath.Join(base, "gone")
|
||||
proj := mk("proj")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cwd string
|
||||
remembered []string
|
||||
want string
|
||||
}{
|
||||
{"chosen directory wins over the list", proj, []string{a}, proj},
|
||||
{"list head", app, []string{a, b}, a},
|
||||
{"stale head skipped", app, []string{gone, b}, b},
|
||||
{"all stale falls to home", app, []string{gone}, home},
|
||||
{"empty list falls to home", app, nil, home},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
home := filepath.Join(base, "state")
|
||||
app := filepath.Join(base, "app")
|
||||
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
|
||||
for _, dir := range []string{home, app, a, b} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Chdir(app)
|
||||
data, _ := json.Marshal(map[string]any{"paths": []string{b, a}, "launch": a})
|
||||
if err := os.WriteFile(filepath.Join(home, "serve-workspaces.json"), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exe := filepath.Join(app, "Reasonix Studio")
|
||||
if got := launchWorkspace(exe); got != a {
|
||||
t.Fatalf("launchWorkspace = %q, want the pre-reorder launch project %q", got, a)
|
||||
}
|
||||
if err := os.Remove(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := launchWorkspace(exe); got != b {
|
||||
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"reasonix/internal/frontend/traystate"
|
||||
"reasonix/internal/platform/account"
|
||||
"reasonix/internal/platform/appupdate"
|
||||
"reasonix/internal/platform/feedback"
|
||||
"reasonix/internal/platform/instanceid"
|
||||
"reasonix/internal/platform/notify"
|
||||
"reasonix/internal/platform/remotecloud"
|
||||
@@ -311,14 +312,19 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
paneSink = reporter.Wrap(paneSink)
|
||||
}
|
||||
}
|
||||
root := boot.ResolveWorkspaceRoot("")
|
||||
root := launchWorkspace(shell.exe)
|
||||
built, err := boot.BuildRuntime(ctx, boot.Options{
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
StatsSource: surface.Desktop,
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -71,3 +71,40 @@ func TestTheHostPointsTheKernelsCatalogueAtTheConfiguredLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweeping a session directory costs a handful of file operations per
|
||||
// transcript, and the window cannot open until the handshake. A build that
|
||||
// sweeps inline makes the first launch after the cache went cold wait on
|
||||
// history it never displays.
|
||||
func TestTheHostsFirstBuildSweepsSessionsInTheBackground(t *testing.T) {
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse main.go: %v", err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := lit.Type.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Options" {
|
||||
return true
|
||||
}
|
||||
for _, elt := range lit.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "CleanupPendingReconciler" {
|
||||
if v, ok := kv.Value.(*ast.SelectorExpr); ok && v.Sel.Name == "BackgroundCleanupReconciler" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("the host's boot.Options does not set CleanupPendingReconciler to serve.BackgroundCleanupReconciler")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var cliChannelSecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(data, &document); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
if len(document.Content) != 1 {
|
||||
t.Fatalf("%s: not a single YAML document", path)
|
||||
}
|
||||
return document.Content[0]
|
||||
}
|
||||
|
||||
func jobsOf(t *testing.T, root *yaml.Node) *yaml.Node {
|
||||
t.Helper()
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil || jobs.Kind != yaml.MappingNode {
|
||||
t.Fatal("workflow has no jobs mapping")
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
func permissionsIssue(node *yaml.Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return "permissions is a " + node.Value + " shorthand, not a mapping"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// secretsRefs reports every way a workflow can reach a named secret: dotted
|
||||
// and subscripted access, and the whole-context forms that expose them all.
|
||||
var secretsWholeContext = regexp.MustCompile(`\bsecrets\b\s*(?:[^.\[\w\s]|$)|toJSON\(\s*secrets\s*\)|\bsecrets\s*\[\s*[^'"\s]`)
|
||||
|
||||
func secretRefPattern(name string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`\bsecrets\s*(?:\.\s*` + name + `\b|\[\s*['"]` + name + `['"]\s*\])`)
|
||||
}
|
||||
|
||||
func scalarsOf(node *yaml.Node, out *[]string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
*out = append(*out, node.Value)
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
scalarsOf(child, out)
|
||||
}
|
||||
}
|
||||
|
||||
func refersTo(node *yaml.Node, names []string) (string, bool) {
|
||||
var scalars []string
|
||||
scalarsOf(node, &scalars)
|
||||
for _, text := range scalars {
|
||||
for _, name := range names {
|
||||
if secretRefPattern(name).MatchString(text) {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
if secretsWholeContext.MatchString(text) && regexp.MustCompile(`\$\{\{`).MatchString(text) {
|
||||
return "the whole secrets context", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
job := mappingValue(jobs, "cli-channels")
|
||||
if job == nil {
|
||||
t.Fatal("cli-channels job is missing")
|
||||
}
|
||||
if mappingValue(job, "environment") != nil {
|
||||
t.Fatal("cli-channels must not declare an environment; the switch and the tag rule are its controls")
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil || permissions.Kind != yaml.MappingNode {
|
||||
t.Fatal("cli-channels must declare its permissions as a mapping")
|
||||
}
|
||||
want := map[string]string{"contents": "read", "id-token": "write"}
|
||||
if len(permissions.Content) != 2*len(want) {
|
||||
t.Fatalf("cli-channels permissions has %d entries, want exactly %v", len(permissions.Content)/2, want)
|
||||
}
|
||||
for scope, level := range want {
|
||||
if got := mappingScalar(permissions, scope); got != level {
|
||||
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
|
||||
}
|
||||
}
|
||||
for _, name := range cliChannelSecrets {
|
||||
if !secretRefPattern(name).MatchString(scalarString(job)) {
|
||||
t.Fatalf("cli-channels does not read secrets.%s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scalarString(node *yaml.Node) string {
|
||||
var scalars []string
|
||||
scalarsOf(node, &scalars)
|
||||
return strings.Join(scalars, "\n")
|
||||
}
|
||||
|
||||
func TestReleaseStudioGrantsNoWriteAll(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
if top := mappingValue(root, "permissions"); top != nil && top.Kind == yaml.ScalarNode && top.Value == "write-all" {
|
||||
t.Error("release-studio.yml grants write-all")
|
||||
}
|
||||
jobs := jobsOf(t, root)
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
if p := mappingValue(job, "permissions"); p != nil && p.Kind == yaml.ScalarNode && p.Value == "write-all" {
|
||||
t.Errorf("release-studio.yml job %s grants write-all", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
if top := mappingValue(root, "permissions"); top != nil {
|
||||
if msg := permissionsIssue(top); msg != "" {
|
||||
t.Errorf("%s: workflow-level %s", file, msg)
|
||||
} else if mappingScalar(top, "id-token") != "" {
|
||||
t.Errorf("%s: id-token at workflow level", file)
|
||||
}
|
||||
}
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil {
|
||||
continue
|
||||
}
|
||||
if msg := permissionsIssue(permissions); msg != "" {
|
||||
t.Errorf("%s job %s: %s", file, name, msg)
|
||||
continue
|
||||
}
|
||||
if mappingScalar(permissions, "id-token") != "" && !(filepath.Base(file) == "release-studio.yml" && name == "cli-channels") {
|
||||
t.Errorf("%s job %s requests id-token; only release-studio.yml cli-channels may", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
guarded := cliChannelSecrets
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
if filepath.Base(file) == "release-studio.yml" {
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
if name == "cli-channels" {
|
||||
continue
|
||||
}
|
||||
if what, found := refersTo(job, guarded); found {
|
||||
t.Errorf("release-studio.yml job %s reaches %s; only cli-channels may", name, what)
|
||||
}
|
||||
}
|
||||
if what, found := refersTo(mappingValue(root, "env"), guarded); found {
|
||||
t.Errorf("release-studio.yml workflow env reaches %s", what)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name, found := refersTo(root, cliChannelSecrets); found {
|
||||
t.Errorf("%s reaches %s, which only release-studio.yml cli-channels may read", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"${{ secrets.NPM_TOKEN }}",
|
||||
"${{ secrets['NPM_TOKEN'] }}",
|
||||
`${{ secrets["NPM_TOKEN"] }}`,
|
||||
"${{ toJSON(secrets) }}",
|
||||
"${{ secrets[format('NPM_{0}', 'TOKEN')] }}",
|
||||
} {
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := refersTo(doc.Content[0], cliChannelSecrets); !found {
|
||||
t.Errorf("reference form not detected: %s", text)
|
||||
}
|
||||
}
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: echo ${{ secrets.R2_BUCKET }}\n"), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := refersTo(doc.Content[0], cliChannelSecrets); found {
|
||||
t.Error("an unrelated secret was reported")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestCLIPointerJobIsSwitchedSerializedAndNarrow(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "cli-pointer")
|
||||
if job == nil {
|
||||
t.Fatal("cli-pointer job is missing")
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-pointer if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
needs := mappingValue(job, "needs")
|
||||
if needs == nil || needs.Kind != yaml.SequenceNode {
|
||||
t.Fatal("cli-pointer must list its needs")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, n := range needs.Content {
|
||||
seen[n.Value] = true
|
||||
}
|
||||
if !seen["resolve"] || !seen["publish"] || !seen["cli-gate"] {
|
||||
t.Fatalf("cli-pointer needs %v, want resolve, publish and cli-gate", seen)
|
||||
}
|
||||
concurrency := mappingValue(job, "concurrency")
|
||||
if concurrency == nil || !strings.HasPrefix(mappingScalar(concurrency, "group"), "studio-cli-pointer-") {
|
||||
t.Fatal("cli-pointer must take its own per-channel lock, never the 1.x workflow's release-cli-<channel> group")
|
||||
}
|
||||
if mappingScalar(concurrency, "cancel-in-progress") != "false" {
|
||||
t.Fatal("cli-pointer must not cancel a run that is mid-write")
|
||||
}
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil || permissions.Kind != yaml.MappingNode || len(permissions.Content) != 2 ||
|
||||
mappingScalar(permissions, "contents") != "write" {
|
||||
t.Fatal("cli-pointer permissions must be exactly contents: write")
|
||||
}
|
||||
if !nodeContains(job, "scripts/publish-cli-pointer.sh") {
|
||||
t.Fatal("cli-pointer must run scripts/publish-cli-pointer.sh")
|
||||
}
|
||||
if !nodeContains(job, "vars.CLI_PUBLISH_FROZEN") {
|
||||
t.Fatal("cli-pointer must pass vars.CLI_PUBLISH_FROZEN to its script")
|
||||
}
|
||||
if name, found := refersTo(job, append([]string{"RELEASE_TAG_TOKEN"}, cliChannelSecrets...)); found {
|
||||
t.Fatalf("cli-pointer reads %s; registry credentials belong to cli-channels only", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
gate := mappingValue(jobs, "cli-gate")
|
||||
if gate == nil {
|
||||
t.Fatal("cli-gate job is missing")
|
||||
}
|
||||
if got := mappingScalar(gate, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-gate if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
if !nodeContains(gate, "vars.CLI_PUBLISH_FROZEN") {
|
||||
t.Fatal("cli-gate must read vars.CLI_PUBLISH_FROZEN")
|
||||
}
|
||||
if nodeContains(gate, "actions/checkout") || nodeContains(gate, "secrets.") {
|
||||
t.Fatal("cli-gate must run no repository code and read no secret")
|
||||
}
|
||||
for _, name := range []string{"cli-tag", "cli-channels", "cli-pointer"} {
|
||||
needs := mappingValue(mappingValue(jobs, name), "needs")
|
||||
found := false
|
||||
for _, n := range needs.Content {
|
||||
found = found || n.Value == "cli-gate"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s does not wait for cli-gate", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"cli-channels", "cli-pointer"} {
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, name), "needs").Content {
|
||||
found = found || n.Value == "cli-tag"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s does not wait for cli-tag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
|
||||
|
||||
func workflowFiles(t *testing.T) []string {
|
||||
t.Helper()
|
||||
var files []string
|
||||
for _, pattern := range []string{"*.yml", "*.yaml"} {
|
||||
found, err := filepath.Glob("../../.github/workflows/" + pattern)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files = append(files, found...)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no workflows found")
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// The token may appear in one inline step of Studio's cli-tag job and in no
|
||||
// other env scope: not the workflow's, not a job's, not another step's.
|
||||
func TestReleaseTagTokenIsReadByOneInlineStep(t *testing.T) {
|
||||
holders := 0
|
||||
for _, file := range workflowFiles(t) {
|
||||
root := parseWorkflowFile(t, file)
|
||||
studio := filepath.Base(file) == "release-studio.yml"
|
||||
for i := 0; i+1 < len(root.Content); i += 2 {
|
||||
if root.Content[i].Value == "jobs" {
|
||||
continue
|
||||
}
|
||||
if what, found := refersTo(root.Content[i+1], releaseTagSecrets); found {
|
||||
t.Errorf("%s reaches %s at workflow level (%s)", file, what, root.Content[i].Value)
|
||||
}
|
||||
}
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
id, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
allowed := studio && id == "cli-tag"
|
||||
for j := 0; j+1 < len(job.Content); j += 2 {
|
||||
key, value := job.Content[j].Value, job.Content[j+1]
|
||||
if key != "steps" {
|
||||
if what, found := refersTo(value, releaseTagSecrets); found {
|
||||
t.Errorf("%s job %s reaches %s in %s; only a step of cli-tag may", file, id, what, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, step := range value.Content {
|
||||
if what, found := refersTo(step, releaseTagSecrets); found {
|
||||
holders++
|
||||
if !allowed {
|
||||
t.Errorf("%s job %s reaches %s; only Studio's cli-tag step may", file, id, what)
|
||||
}
|
||||
if mappingScalar(step, "uses") != "" {
|
||||
t.Errorf("%s job %s: the token step must be inline shell, not an action", file, id)
|
||||
}
|
||||
} else if allowed && mappingScalar(step, "uses") != "" {
|
||||
t.Errorf("cli-tag must check out nothing and run no action")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if holders != 1 {
|
||||
t.Fatalf("%d steps read RELEASE_TAG_TOKEN, want exactly one", holders)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -7,9 +7,10 @@ const { chromium } = require("playwright");
|
||||
|
||||
const ASSETS = path.join(__dirname, "..", "assets");
|
||||
const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
|
||||
// The tray is drawn at 16 logical pixels; nativeImage picks the @Nx file that
|
||||
// matches the display, so a scaled screen never enlarges the 16px one.
|
||||
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [2, "@2x"], [3, "@3x"]];
|
||||
// The tray is drawn at 16 logical pixels. Windows asks for exactly 16*scale
|
||||
// pixels and resizes whatever it is handed, so every Windows scale has its own
|
||||
// file (src/trayimage.js picks it).
|
||||
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
|
||||
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
|
||||
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
|
||||
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
|
||||
|
||||
@@ -1,21 +1,131 @@
|
||||
"use strict";
|
||||
|
||||
// The context menu's shape, kept apart from the platform so it can be checked
|
||||
// without one. editFlags is the page's own account of what is possible at the
|
||||
// click: roles do the clipboard work, this only decides what to offer.
|
||||
function contextTemplate(params) {
|
||||
if (!params.isEditable && !params.selectionText) return [];
|
||||
const can = params.editFlags ?? {};
|
||||
// Role labels come from the platform's own catalogue, which follows the
|
||||
// system locale rather than the language the interface was set to, so they are
|
||||
// stated here for the two languages the interface draws.
|
||||
const WORDS = {
|
||||
zh: {
|
||||
edit: "编辑",
|
||||
undo: "撤销",
|
||||
redo: "重做",
|
||||
cut: "剪切",
|
||||
copy: "复制",
|
||||
paste: "粘贴",
|
||||
selectAll: "全选",
|
||||
pasteAndMatchStyle: "粘贴并匹配样式",
|
||||
delete: "删除",
|
||||
substitutions: "替换",
|
||||
showSubstitutions: "显示替换",
|
||||
smartQuotes: "智能引号",
|
||||
smartDashes: "智能破折号",
|
||||
textReplacement: "文本替换",
|
||||
speech: "语音",
|
||||
startSpeaking: "开始朗读",
|
||||
stopSpeaking: "停止朗读",
|
||||
},
|
||||
en: {
|
||||
edit: "Edit",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
cut: "Cut",
|
||||
copy: "Copy",
|
||||
paste: "Paste",
|
||||
selectAll: "Select All",
|
||||
pasteAndMatchStyle: "Paste and Match Style",
|
||||
delete: "Delete",
|
||||
substitutions: "Substitutions",
|
||||
showSubstitutions: "Show Substitutions",
|
||||
smartQuotes: "Smart Quotes",
|
||||
smartDashes: "Smart Dashes",
|
||||
textReplacement: "Text Replacement",
|
||||
speech: "Speech",
|
||||
startSpeaking: "Start Speaking",
|
||||
stopSpeaking: "Stop Speaking",
|
||||
},
|
||||
};
|
||||
|
||||
function wordsFor(lang) {
|
||||
return WORDS[lang] ?? WORDS.en;
|
||||
}
|
||||
|
||||
// The edit commands as roles carrying explicit labels: roles do the clipboard
|
||||
// work, the labels follow the interface language.
|
||||
function editItems(lang, can) {
|
||||
const w = wordsFor(lang);
|
||||
const on = (flag) => (can ? { enabled: !!can[flag] } : {});
|
||||
return [
|
||||
{ role: "undo", enabled: !!can.canUndo },
|
||||
{ role: "redo", enabled: !!can.canRedo },
|
||||
{ role: "undo", label: w.undo, ...on("canUndo") },
|
||||
{ role: "redo", label: w.redo, ...on("canRedo") },
|
||||
{ type: "separator" },
|
||||
{ role: "cut", enabled: !!can.canCut },
|
||||
{ role: "copy", enabled: !!can.canCopy },
|
||||
{ role: "paste", enabled: !!can.canPaste },
|
||||
{ role: "cut", label: w.cut, ...on("canCut") },
|
||||
{ role: "copy", label: w.copy, ...on("canCopy") },
|
||||
{ role: "paste", label: w.paste, ...on("canPaste") },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll", enabled: !!can.canSelectAll },
|
||||
{ role: "selectAll", label: w.selectAll, ...on("canSelectAll") },
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = { contextTemplate };
|
||||
// The context menu's shape, kept apart from the platform so it can be checked
|
||||
// without one. editFlags is the page's own account of what is possible at the
|
||||
// click: this only decides what to offer.
|
||||
function contextTemplate(params, lang) {
|
||||
if (!params.isEditable && !params.selectionText) return [];
|
||||
return editItems(lang, params.editFlags ?? {});
|
||||
}
|
||||
|
||||
// Everything the platform's own Edit menu carries, with the same roles, so
|
||||
// only the words change.
|
||||
function editMenuTemplate(lang) {
|
||||
const w = wordsFor(lang);
|
||||
const items = editItems(lang);
|
||||
const at = items.findIndex((i) => i.role === "selectAll");
|
||||
items.splice(at - 1, 1);
|
||||
items.splice(at - 1, 0,
|
||||
{ role: "pasteAndMatchStyle", label: w.pasteAndMatchStyle },
|
||||
{ role: "delete", label: w.delete });
|
||||
items.push(
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: w.substitutions,
|
||||
submenu: [
|
||||
{ role: "showSubstitutions", label: w.showSubstitutions },
|
||||
{ type: "separator" },
|
||||
{ role: "toggleSmartQuotes", label: w.smartQuotes },
|
||||
{ role: "toggleSmartDashes", label: w.smartDashes },
|
||||
{ role: "toggleTextReplacement", label: w.textReplacement },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: w.speech,
|
||||
submenu: [
|
||||
{ role: "startSpeaking", label: w.startSpeaking },
|
||||
{ role: "stopSpeaking", label: w.stopSpeaking },
|
||||
],
|
||||
},
|
||||
);
|
||||
return { label: w.edit, submenu: items };
|
||||
}
|
||||
|
||||
function applicationMenuTemplate(lang) {
|
||||
return [{ role: "appMenu" }, editMenuTemplate(lang), { role: "windowMenu" }];
|
||||
}
|
||||
|
||||
// Only the Edit menu follows the interface language; the application and window
|
||||
// menus are roles and follow the OS. Menu is injected so this runs without Electron.
|
||||
function menuInstaller(Menu, platform = process.platform) {
|
||||
let installedLang = null;
|
||||
return function installApplicationMenu(languageOf = () => "en") {
|
||||
if (platform !== "darwin") {
|
||||
Menu.setApplicationMenu(null);
|
||||
return null;
|
||||
}
|
||||
const lang = languageOf();
|
||||
if (lang === installedLang) return Menu.getApplicationMenu();
|
||||
installedLang = lang;
|
||||
const menu = Menu.buildFromTemplate(applicationMenuTemplate(lang));
|
||||
Menu.setApplicationMenu(menu);
|
||||
return menu;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller };
|
||||
@@ -5,22 +5,40 @@ const PROTOCOL_VERSION = 1;
|
||||
// A child that never sends a handshake must not be able to grow this process
|
||||
// while it waits, nor hold the launch open forever.
|
||||
const HANDSHAKE_LIMIT = 4096;
|
||||
const HANDSHAKE_TIMEOUT_MS = 20000;
|
||||
const HANDSHAKE_TIMEOUT_MS = 60000;
|
||||
const SLOW_AFTER_MS = 3000;
|
||||
|
||||
// HostStartError says why a launch has no kernel. code is one of "timeout",
|
||||
// "exited", "spawn", "overflow" or "malformed"; callers branch on it, never on
|
||||
// the sentence.
|
||||
class HostStartError extends Error {
|
||||
constructor(code, message, detail = {}) {
|
||||
super(message);
|
||||
this.name = "HostStartError";
|
||||
this.code = code;
|
||||
Object.assign(this, detail);
|
||||
}
|
||||
}
|
||||
|
||||
// start spawns the kernel and reads the handshake it writes to stdout. Every
|
||||
// line after it is an act a handover asks of this process, because that pipe is
|
||||
// the only channel pointing this way. stdin is the lease: the kernel drains
|
||||
// when this end closes, which is what stops it outliving a parent that exited
|
||||
// without being asked to.
|
||||
function start(binary, args, { onStderr, onExit, onAct } = {}) {
|
||||
function start(binary, args, { onStderr, onExit, onAct, onSlow, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
child.stderr.setEncoding("utf8");
|
||||
if (onStderr) child.stderr.on("data", onStderr);
|
||||
if (onExit) child.on("exit", onExit);
|
||||
const ready = firstLine(child).then(({ line, rest }) => {
|
||||
const ready = firstLine(child, { onSlow, timeoutMs }).then(({ line, rest }) => {
|
||||
// Only once the handshake parses: a child whose first line was not one has
|
||||
// said nothing this process should act on.
|
||||
const handshake = parse(line);
|
||||
let handshake;
|
||||
try {
|
||||
handshake = parse(line);
|
||||
} catch (err) {
|
||||
throw new HostStartError("malformed", err.message);
|
||||
}
|
||||
if (onAct) readActs(child, rest, onAct);
|
||||
return handshake;
|
||||
});
|
||||
@@ -60,19 +78,31 @@ function readActs(child, rest, onAct) {
|
||||
drain();
|
||||
}
|
||||
|
||||
function firstLine(child) {
|
||||
function firstLine(child, { onSlow, timeoutMs }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffered = "";
|
||||
const began = Date.now();
|
||||
const elapsed = () => Date.now() - began;
|
||||
const settle = (fn, value) => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(slow);
|
||||
child.stdout.off("data", onData);
|
||||
child.off("exit", onEarlyExit);
|
||||
child.off("error", onSpawnFailure);
|
||||
fn(value);
|
||||
};
|
||||
const slow = onSlow ? setTimeout(onSlow, Math.min(SLOW_AFTER_MS, timeoutMs)) : undefined;
|
||||
const timer = setTimeout(
|
||||
() => settle(reject, new Error("the kernel sent no handshake")),
|
||||
HANDSHAKE_TIMEOUT_MS,
|
||||
() =>
|
||||
settle(
|
||||
reject,
|
||||
new HostStartError(
|
||||
"timeout",
|
||||
`the kernel was still running but sent no handshake within ${Math.round(timeoutMs / 1000)} s`,
|
||||
{ elapsedMs: elapsed() },
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
const onData = (chunk) => {
|
||||
buffered += chunk;
|
||||
@@ -81,15 +111,22 @@ function firstLine(child) {
|
||||
return settle(resolve, { line: buffered.slice(0, end), rest: buffered.slice(end + 1) });
|
||||
}
|
||||
if (buffered.length > HANDSHAKE_LIMIT) {
|
||||
settle(reject, new Error("the handshake ran past its limit"));
|
||||
settle(reject, new HostStartError("overflow", "the handshake ran past its limit"));
|
||||
}
|
||||
};
|
||||
const onEarlyExit = (code) =>
|
||||
settle(reject, new Error(`the kernel exited with ${code} before saying anything`));
|
||||
const onEarlyExit = (code, signal) =>
|
||||
settle(
|
||||
reject,
|
||||
new HostStartError("exited", `the kernel exited with ${code ?? `signal ${signal}`} before saying anything`, {
|
||||
exitCode: code,
|
||||
signal,
|
||||
elapsedMs: elapsed(),
|
||||
}),
|
||||
);
|
||||
// A program that could not be started emits this and never exits, so without
|
||||
// it the launch waited out the handshake timeout and then reported the
|
||||
// kernel as silent — which sent the reader looking at the kernel.
|
||||
const onSpawnFailure = (err) => settle(reject, new Error(`the kernel could not be started: ${err.message}`));
|
||||
const onSpawnFailure = (err) => settle(reject, new HostStartError("spawn", `the kernel could not be started: ${err.message}`, { elapsedMs: elapsed() }));
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", onData);
|
||||
child.on("exit", onEarlyExit);
|
||||
@@ -128,4 +165,12 @@ function parse(line) {
|
||||
return { origin: url.origin, token: body.token };
|
||||
}
|
||||
|
||||
module.exports = { start, parse, readActs, PROTOCOL_VERSION };
|
||||
const RETRY_WITHIN_MS = 10000;
|
||||
|
||||
// shouldRetry allows one more attempt only for a kernel that stopped, or could
|
||||
// not be run, soon after it was started.
|
||||
function shouldRetry(err, attempt, elapsedMs) {
|
||||
return attempt < 2 && (err?.code === "exited" || err?.code === "spawn") && elapsedMs <= RETRY_WITHIN_MS;
|
||||
}
|
||||
|
||||
module.exports = { shouldRetry, start, parse, readActs, HostStartError, PROTOCOL_VERSION, HANDSHAKE_TIMEOUT_MS };
|
||||
@@ -7,15 +7,17 @@ if (relaunchForOzonePlatform(app, process)) return;
|
||||
const fs = require("node:fs/promises");
|
||||
const { existsSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { start } = require("./host");
|
||||
const { start, shouldRetry, HANDSHAKE_TIMEOUT_MS } = require("./host");
|
||||
const { showStarting } = require("./starting");
|
||||
const { StudioHost } = require("./hostclient");
|
||||
const { installTray } = require("./tray");
|
||||
const { instanceID, profileFor } = require("./instance");
|
||||
const { installApplicationMenu, installContextMenu } = require("./menu");
|
||||
const { uiLanguage } = require("./uilang");
|
||||
const { installFullScreenKey } = require("./fullscreen");
|
||||
const { installReload } = require("./reload");
|
||||
const { externalTarget } = require("./links");
|
||||
const { reveal } = require("./reveal");
|
||||
const { reveal, revealWorkspace } = require("./reveal");
|
||||
const { appIcon } = require("./appicon");
|
||||
const layout = require("./layout");
|
||||
const { offerCleanup } = require("./legacy");
|
||||
@@ -23,7 +25,7 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
|
||||
const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { prefsFile, registerPrefs } = require("./prefs");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
|
||||
// A page in a minimized or fully covered window counts as hidden, and a hidden
|
||||
@@ -45,6 +47,7 @@ const LIGHTS = { x: 11, y: 20 };
|
||||
const DEFAULT_SIZE = { width: 1440, height: 900 };
|
||||
const MIN_SIZE = { width: 760, height: 480 };
|
||||
const HOST_DRAIN_MS = 1000;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
const where = {
|
||||
packaged: app.isPackaged,
|
||||
@@ -70,6 +73,69 @@ let reload = null;
|
||||
let logs = null;
|
||||
let handshaken = false;
|
||||
|
||||
let starting = null;
|
||||
function closeStarting() {
|
||||
const win = starting;
|
||||
starting = null;
|
||||
if (win && !win.isDestroyed()) win.close();
|
||||
}
|
||||
|
||||
function handshakeTimeout() {
|
||||
const ms = Number(process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS);
|
||||
return Number.isFinite(ms) && ms > 0 ? ms : HANDSHAKE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// A kernel that exits or cannot be spawned within seconds is tried once more:
|
||||
// a scanner holding a freshly written binary lets go about that fast. A slow
|
||||
// kernel is waited for instead, never restarted.
|
||||
async function launchKernel(args) {
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
if (quitting) throw new Error("the launch was ended by quit");
|
||||
const began = Date.now();
|
||||
logs.shell.line(`host: starting ${hostBinary} (attempt ${attempt})`);
|
||||
kernel = start(hostBinary, args, {
|
||||
timeoutMs: handshakeTimeout(),
|
||||
onSlow: () => {
|
||||
logs.shell.line("host: no handshake yet; showing the starting window");
|
||||
if (starting || quitting) return;
|
||||
const win = showStarting(app.getLocale());
|
||||
starting = win;
|
||||
win.once("closed", () => {
|
||||
if (starting !== win) return;
|
||||
starting = null;
|
||||
logs.shell.line("host: starting window closed; ending the launch");
|
||||
app.quit();
|
||||
});
|
||||
},
|
||||
onStderr: (text) => {
|
||||
logs.host.raw(text);
|
||||
process.stderr.write(text);
|
||||
},
|
||||
onExit: (code, signal) => {
|
||||
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
|
||||
// Before the handshake the launch itself fails, and boot's catch owns
|
||||
// telling the person why; quitting here would race that dialog.
|
||||
if (handshaken && code !== 0 && !quitting) app.quit();
|
||||
},
|
||||
onAct: handOver,
|
||||
});
|
||||
const current = kernel;
|
||||
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
|
||||
current.child.stderr.on("close", () => logs.host.flush());
|
||||
try {
|
||||
return await current.ready;
|
||||
} catch (err) {
|
||||
if (quitting || !shouldRetry(err, attempt, Date.now() - began)) {
|
||||
err.attempts = attempt;
|
||||
closeStarting();
|
||||
throw err;
|
||||
}
|
||||
logs.shell.line(`host: ${err.message}; retrying once`);
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
// Which build this is belongs to the shell: inside the bundle the kernel's
|
||||
// own os.Executable() names the host binary, not the application around it.
|
||||
@@ -86,23 +152,7 @@ async function boot() {
|
||||
// inside the bundle rather than being it.
|
||||
args.push("-studio-app", process.execPath, "-studio-app-pid", String(process.pid));
|
||||
}
|
||||
logs.shell.line(`host: starting ${hostBinary}`);
|
||||
kernel = start(hostBinary, args, {
|
||||
onStderr: (text) => {
|
||||
logs.host.raw(text);
|
||||
process.stderr.write(text);
|
||||
},
|
||||
onExit: (code, signal) => {
|
||||
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
|
||||
// Before the handshake the launch itself fails, and boot's catch owns
|
||||
// telling the person why; quitting here would race that dialog.
|
||||
if (handshaken && code !== 0 && !quitting) app.quit();
|
||||
},
|
||||
onAct: handOver,
|
||||
});
|
||||
kernel.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
|
||||
kernel.child.stderr.on("close", () => logs.host.flush());
|
||||
const ready = await kernel.ready;
|
||||
const ready = await launchKernel(args);
|
||||
logs.addSecret(ready.token);
|
||||
handshaken = true;
|
||||
logs.shell.line(`host: handshake from ${ready.origin}`);
|
||||
@@ -110,13 +160,14 @@ async function boot() {
|
||||
client = new StudioHost(ready.origin, ready.token);
|
||||
await armCredential(ready);
|
||||
win = createWindow();
|
||||
closeStarting();
|
||||
guard(win.webContents);
|
||||
win.webContents.once("render-process-gone", (_event, details) => {
|
||||
if (win.isVisible() || details.reason !== "crashed") return;
|
||||
const cause = unpaintedWindowCause(grants, app.getLocale());
|
||||
if (cause) dialog.showErrorBox(cause.title, cause.detail);
|
||||
});
|
||||
installContextMenu(win.webContents, win);
|
||||
installContextMenu(win.webContents, win, uiLang);
|
||||
installFullScreenKey(win.webContents, win);
|
||||
reload = installReload(win.webContents, win);
|
||||
win.once("ready-to-show", () => win.show());
|
||||
@@ -201,6 +252,7 @@ function onWindowClose(event) {
|
||||
// showWindow brings it back from wherever it went. Show alone is a no-op on a
|
||||
// window that is merely buried, so the focus is what actually raises it.
|
||||
function showWindow() {
|
||||
if (starting && !starting.isDestroyed()) starting.focus();
|
||||
if (quitting || !win || win.isDestroyed()) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
reload?.revive();
|
||||
@@ -263,7 +315,10 @@ function fromWindow(event) {
|
||||
return win && !win.isDestroyed() && event.sender === win.webContents ? win : null;
|
||||
}
|
||||
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow);
|
||||
// The page reads navigator.languages[0], which is the first preferred system
|
||||
// language; app.getLocale() is the locale Chromium resolved, which can differ.
|
||||
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => installApplicationMenu(uiLang));
|
||||
|
||||
ipcMain.handle("window:minimise", (event) => {
|
||||
fromWindow(event)?.minimize();
|
||||
@@ -300,8 +355,11 @@ ipcMain.handle("browser:login-answer", (event, id, username, password) => {
|
||||
ipcMain.handle("shell:open-external", (event, raw) => {
|
||||
if (!fromWindow(event)) return;
|
||||
const target = externalTarget(raw);
|
||||
if (target) void shell.openExternal(target);
|
||||
if (target) return shell.openExternal(target);
|
||||
});
|
||||
ipcMain.handle("shell:reveal-workspace", (event, root) =>
|
||||
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:reveal", (event, base, rel) =>
|
||||
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
|
||||
);
|
||||
@@ -377,7 +435,7 @@ if (!primary) {
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (!primary) return;
|
||||
installApplicationMenu();
|
||||
installApplicationMenu(uiLang);
|
||||
boot().catch((err) => {
|
||||
console.error("reasonix-studio:", err.message);
|
||||
if (quitting) {
|
||||
@@ -423,7 +481,11 @@ function handOver(act) {
|
||||
if (act === "quit") app.quit();
|
||||
}
|
||||
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
// Before the handshake the only window is the starting one, and the launch
|
||||
// decides for itself whether to go on.
|
||||
app.on("window-all-closed", () => {
|
||||
if (handshaken) app.quit();
|
||||
});
|
||||
|
||||
// Closing this end of the pipe is what tells the kernel to drain. Without it a
|
||||
// session file is left being written by a process nobody is holding open.
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
"use strict";
|
||||
const { Menu } = require("electron");
|
||||
const { contextTemplate } = require("./editmenu");
|
||||
const { contextTemplate, menuInstaller } = require("./editmenu");
|
||||
|
||||
// Without an application menu a WebContents has no copy, paste or undo at all:
|
||||
// macOS routes those shortcuts through it, and a window with none reads as a
|
||||
// broken text editor. Elsewhere the bar would render inside the window, and
|
||||
// those platforms bind the shortcuts themselves.
|
||||
function installApplicationMenu() {
|
||||
if (process.platform !== "darwin") {
|
||||
Menu.setApplicationMenu(null);
|
||||
return null;
|
||||
}
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ role: "appMenu" },
|
||||
{ role: "editMenu" },
|
||||
{ role: "windowMenu" },
|
||||
]);
|
||||
Menu.setApplicationMenu(menu);
|
||||
return menu;
|
||||
}
|
||||
const installApplicationMenu = menuInstaller(Menu);
|
||||
|
||||
// A production build ships without one, so a right-click in a text field offered
|
||||
// nothing at all. Roles rather than handlers: the clipboard work belongs to the
|
||||
// platform, and editFlags is the page's own account of what is possible here.
|
||||
function installContextMenu(contents, window) {
|
||||
function installContextMenu(contents, window, languageOf = () => "en") {
|
||||
contents.on("context-menu", (_event, params) => {
|
||||
const template = contextTemplate(params);
|
||||
const template = contextTemplate(params, languageOf());
|
||||
if (!template.length) return;
|
||||
Menu.buildFromTemplate(template).popup({ window });
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ function savePrefs(file, prefs) {
|
||||
// in the same process and must not reach this file.
|
||||
// fileOf is asked on each use: the profile directory is settled during boot,
|
||||
// after these handlers exist.
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow) {
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
|
||||
ipcMain.on("prefs:load", (event) => {
|
||||
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
|
||||
});
|
||||
@@ -52,6 +52,7 @@ function registerPrefs(ipcMain, fileOf, fromWindow) {
|
||||
if (fromWindow(event)) {
|
||||
try {
|
||||
savePrefs(fileOf(), prefs);
|
||||
onSave();
|
||||
} catch {
|
||||
// A preference that did not save is one relaunch of lost state, not a
|
||||
// reason to fail the window.
|
||||
|
||||
@@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
|
||||
// A pane and a path inside its workspace; main asks the kernel where that is.
|
||||
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
|
||||
// A project the sidebar lists, which may have no pane at all.
|
||||
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
|
||||
// Where a dropped file lives. Resolved here rather than in the page: the
|
||||
// renderer is handed a File and never a path, and a turn that has to work on
|
||||
// the file itself cannot do it on a copy of the bytes.
|
||||
|
||||
@@ -23,9 +23,13 @@ function local(target, platform) {
|
||||
async function locate(client, base, rel, platform) {
|
||||
if (!PANE_BASE.test(base)) return { refusal: refused("not a pane this window publishes") };
|
||||
const query = rel ? `?path=${encodeURIComponent(rel)}` : "";
|
||||
return ask(client, `${base}/workspace/locate${query}`, platform);
|
||||
}
|
||||
|
||||
async function ask(client, url, platform) {
|
||||
let res;
|
||||
try {
|
||||
res = await client.request("GET", `${base}/workspace/locate${query}`);
|
||||
res = await client.request("GET", url);
|
||||
} catch (err) {
|
||||
return { refusal: refused(err.message) };
|
||||
}
|
||||
@@ -54,4 +58,13 @@ async function reveal(client, shell, base, rel, platform = process.platform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { reveal };
|
||||
// A project the sidebar lists needs no pane: the hub answers for the folders
|
||||
// it lists and refuses any other, so the page still names no location itself.
|
||||
async function revealWorkspace(client, shell, root, platform = process.platform) {
|
||||
const found = await ask(client, `/host/workspaces/locate?root=${encodeURIComponent(String(root))}`, platform);
|
||||
if (found.refusal) return found.refusal;
|
||||
shell.showItemInFolder(found.target);
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace };
|
||||
@@ -159,17 +159,29 @@ function openLogs(userData, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function startupFailure(locale, reason, logDir) {
|
||||
if (String(locale).toLowerCase().startsWith("zh")) {
|
||||
return {
|
||||
title: "Reasonix Studio 无法启动",
|
||||
detail: `${reason}\n\n日志位于:\n${logDir}`,
|
||||
};
|
||||
const HINTS = {
|
||||
timeout: [
|
||||
"The kernel started but did not finish starting in time. Security software scanning a new or updated install is a common cause; starting Studio again usually works.",
|
||||
"内核已启动,但没有在限定时间内完成启动。安全软件扫描新安装或刚更新的程序是常见原因;再次启动通常可以。",
|
||||
],
|
||||
exited: [
|
||||
"The kernel process stopped before it was ready. Anything it printed is above; host.log has the rest.",
|
||||
"内核进程在就绪前就停止了。它输出过的内容在上面,host.log 里有更多。",
|
||||
],
|
||||
spawn: [
|
||||
"The kernel program could not be run. Security software blocking or locking it is a common cause.",
|
||||
"无法运行内核程序。安全软件拦截或占用它是常见原因。",
|
||||
],
|
||||
};
|
||||
|
||||
function startupFailure(locale, reason, logDir, code) {
|
||||
const zh = String(locale).toLowerCase().startsWith("zh");
|
||||
const hint = HINTS[code]?.[zh ? 1 : 0];
|
||||
const body = hint ? `${reason}\n\n${hint}` : reason;
|
||||
if (zh) {
|
||||
return { title: "Reasonix Studio 无法启动", detail: `${body}\n\n日志位于:\n${logDir}` };
|
||||
}
|
||||
return {
|
||||
title: "Reasonix Studio could not start",
|
||||
detail: `${reason}\n\nLogs are in:\n${logDir}`,
|
||||
};
|
||||
return { title: "Reasonix Studio could not start", detail: `${body}\n\nLogs are in:\n${logDir}` };
|
||||
}
|
||||
|
||||
// failStartup is the one way a launch that cannot come up ends: recorded, told
|
||||
@@ -177,8 +189,9 @@ function startupFailure(locale, reason, logDir) {
|
||||
// words when it never finished its handshake, and is already redacted.
|
||||
function failStartup({ app, dialog, logs, locale }, err, hostOutput = "") {
|
||||
logs.shell.line(`startup failed: ${err?.stack || err}`);
|
||||
const reason = [String(err?.message || err), hostOutput].filter(Boolean).join("\n\n");
|
||||
const text = startupFailure(locale, logs.redact(reason), logs.dir);
|
||||
const tried = err?.attempts > 1 ? ` (after ${err.attempts} attempts)` : "";
|
||||
const reason = [`${String(err?.message || err)}${tried}`, hostOutput].filter(Boolean).join("\n\n");
|
||||
const text = startupFailure(locale, logs.redact(reason), logs.dir, err?.code);
|
||||
try {
|
||||
dialog.showErrorBox(text.title, text.detail);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
const { BrowserWindow } = require("electron");
|
||||
|
||||
const TEXT = {
|
||||
en: ["Reasonix Studio is starting", "The first start after installing or rebooting can take a minute."],
|
||||
zh: ["Reasonix Studio 正在启动", "安装或重启电脑后的首次启动可能需要一分钟。"],
|
||||
};
|
||||
|
||||
function startingPage(locale) {
|
||||
const [title, note] = String(locale).toLowerCase().startsWith("zh") ? TEXT.zh : TEXT.en;
|
||||
return `<!doctype html><meta charset="utf-8"><title>${title}</title><style>
|
||||
:root{color-scheme:light dark;--bg:#fafafa;--fg:#1c1c1e;--mute:#6b6b70}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#1b1b1d;--fg:#ececee;--mute:#9a9aa0}}
|
||||
body{margin:0;height:100vh;display:flex;flex-direction:column;justify-content:center;gap:8px;padding:0 28px;box-sizing:border-box;background:var(--bg);color:var(--fg);font:14px system-ui,sans-serif}
|
||||
h1{margin:0;font-size:16px;font-weight:600}p{margin:0;color:var(--mute);line-height:1.5}
|
||||
.bar{height:3px;margin-top:10px;border-radius:2px;background:var(--mute);opacity:.35;overflow:hidden;position:relative}
|
||||
.bar::after{content:"";position:absolute;inset:0 70% 0 0;background:var(--fg);animation:run 1.4s ease-in-out infinite}
|
||||
@keyframes run{from{transform:translateX(-100%)}to{transform:translateX(340%)}}
|
||||
</style><h1>${title}</h1><p>${note}</p><div class="bar"></div>`;
|
||||
}
|
||||
|
||||
// showStarting is the only sign of life while the kernel has not answered: until
|
||||
// it does there is no window of the application's own to draw.
|
||||
function showStarting(locale) {
|
||||
const win = new BrowserWindow({
|
||||
width: 380,
|
||||
height: 150,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
frame: true,
|
||||
title: "Reasonix Studio",
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false },
|
||||
});
|
||||
win.removeMenu();
|
||||
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startingPage(locale))}`);
|
||||
return win;
|
||||
}
|
||||
|
||||
module.exports = { showStarting, startingPage };
|
||||
@@ -1,18 +1,17 @@
|
||||
"use strict";
|
||||
const path = require("node:path");
|
||||
const { Menu, Tray, nativeImage } = require("electron");
|
||||
const { Menu, Tray, nativeImage, screen } = require("electron");
|
||||
const { trayImage } = require("./trayimage.js");
|
||||
|
||||
// How often the fold is re-read. It is a projection the kernel recomputes on
|
||||
// demand, so a missed read costs nothing the next one does not restore — which
|
||||
// is why it is pulled on a timer rather than carried on the stream.
|
||||
const FOLD_INTERVAL_MS = 5000;
|
||||
const ICON = path.join(__dirname, "..", "assets", "tray.png");
|
||||
|
||||
// installTray brings the icon up and reports whether there is one. A shell with
|
||||
// no icon must never background its window: hiding it would leave a running
|
||||
// session with nothing left to bring it back.
|
||||
function installTray(host, { onOpen, onQuit }) {
|
||||
const image = nativeImage.createFromPath(ICON);
|
||||
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
|
||||
if (image.isEmpty()) return null;
|
||||
const icon = new Tray(image);
|
||||
const state = { fold: null, prefs: null };
|
||||
@@ -47,6 +46,9 @@ function installTray(host, { onOpen, onQuit }) {
|
||||
);
|
||||
};
|
||||
|
||||
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
|
||||
if (process.platform === "win32") screen.on("display-metrics-changed", refit);
|
||||
|
||||
icon.on("click", onOpen);
|
||||
const timer = setInterval(() => void refresh(), FOLD_INTERVAL_MS);
|
||||
|
||||
@@ -68,6 +70,7 @@ function installTray(host, { onOpen, onQuit }) {
|
||||
refresh,
|
||||
close() {
|
||||
clearInterval(timer);
|
||||
screen.removeListener("display-metrics-changed", refit);
|
||||
icon.destroy();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ASSETS = path.join(__dirname, "..", "assets");
|
||||
const SCALES = [
|
||||
[1, ""],
|
||||
[1.25, "@1.25x"],
|
||||
[1.5, "@1.5x"],
|
||||
[1.75, "@1.75x"],
|
||||
[2, "@2x"],
|
||||
[2.5, "@2.5x"],
|
||||
[3, "@3x"],
|
||||
];
|
||||
|
||||
function trayAsset(scaleFactor) {
|
||||
const want = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1;
|
||||
const hit = SCALES.find(([scale]) => scale >= want - 1e-6) ?? SCALES[SCALES.length - 1];
|
||||
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
|
||||
}
|
||||
|
||||
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
|
||||
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
|
||||
// platforms choose among the @Nx files themselves.
|
||||
function trayImage(nativeImage, scaleFactor) {
|
||||
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
|
||||
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
|
||||
}
|
||||
|
||||
module.exports = { trayAsset, trayImage, SCALES };
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
// The interface language the page will draw in, decided from the same two
|
||||
// facts it uses: its saved choice (rx-lang) and, for anything but an explicit
|
||||
// zh or en, the machine's first locale.
|
||||
function isChinese(tag) {
|
||||
const s = String(tag ?? "").toLowerCase();
|
||||
return s.startsWith("zh") || s.startsWith("yue") || s.startsWith("cmn") || s.includes("hans") || s.includes("hant");
|
||||
}
|
||||
|
||||
function uiLanguage(prefs, machineLocale) {
|
||||
const want = String(prefs?.["rx-lang"] ?? "").trim().toLowerCase();
|
||||
if (want === "zh" || want === "en") return want;
|
||||
return isChinese(machineLocale) ? "zh" : "en";
|
||||
}
|
||||
|
||||
module.exports = { uiLanguage };
|
||||
@@ -303,11 +303,11 @@ async function menuChecks(win, js) {
|
||||
const menu = Menu.getApplicationMenu();
|
||||
const roles = (menu?.items ?? []).map((i) => String(i.role).toLowerCase());
|
||||
check("the application menu is installed", !!menu);
|
||||
check("it carries the three menus macOS routes through", ["appmenu", "editmenu", "windowmenu"].every((r) => roles.includes(r)), roles);
|
||||
check("it carries the three menus macOS routes through", ["appmenu", "windowmenu"].every((r) => roles.includes(r)) && menu.items.length === 3, roles);
|
||||
|
||||
const edit = (menu?.items ?? []).find((i) => String(i.role).toLowerCase() === "editmenu");
|
||||
const edit = (menu?.items ?? [])[1];
|
||||
const editRoles = (edit?.submenu?.items ?? []).map((i) => String(i.role).toLowerCase());
|
||||
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "selectall"].every((r) => editRoles.includes(r)), editRoles);
|
||||
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "pasteandmatchstyle", "delete", "selectall"].every((r) => editRoles.includes(r)), editRoles);
|
||||
|
||||
app.focus({ steal: true });
|
||||
win.focus();
|
||||
|
||||
@@ -7,7 +7,8 @@ import fs from "node:fs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { parse, readActs } = require("../src/host.js");
|
||||
const { contextTemplate } = require("../src/editmenu.js");
|
||||
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
|
||||
const { uiLanguage } = require("../src/uilang.js");
|
||||
const { externalTarget } = require("../src/links.js");
|
||||
const { offerCleanup, ownBundle } = require("../src/legacy.js");
|
||||
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
|
||||
@@ -63,6 +64,68 @@ test("the context menu mirrors what the page says is possible", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("the edit menu's labels follow the interface language, not the system's", () => {
|
||||
const params = { isEditable: true, selectionText: "", editFlags: {} };
|
||||
const labels = (lang) => contextTemplate(params, lang).filter((i) => i.role).map((i) => i.label);
|
||||
assert.deepEqual(labels("zh"), ["撤销", "重做", "剪切", "复制", "粘贴", "全选"]);
|
||||
assert.deepEqual(labels("en"), ["Undo", "Redo", "Cut", "Copy", "Paste", "Select All"]);
|
||||
assert.equal(editMenuTemplate("zh").label, "编辑");
|
||||
});
|
||||
|
||||
test("the edit menu keeps everything the platform's own one carries", () => {
|
||||
const roles = (items) => items.flatMap((i) => [i.role, ...(i.submenu ? roles(i.submenu) : [])]).filter(Boolean);
|
||||
const want = ["undo", "redo", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll",
|
||||
"showSubstitutions", "toggleSmartQuotes", "toggleSmartDashes", "toggleTextReplacement", "startSpeaking", "stopSpeaking"];
|
||||
for (const lang of ["zh", "en"]) {
|
||||
assert.deepEqual(roles(editMenuTemplate(lang).submenu), want);
|
||||
const labels = (items) => items.flatMap((i) => [i.label, ...(i.submenu ? labels(i.submenu) : [])]).filter((l) => l !== undefined);
|
||||
assert.ok(labels(editMenuTemplate(lang).submenu).every((l) => l.length > 0));
|
||||
}
|
||||
assert.deepEqual(editMenuTemplate("en").submenu.map((i) => i.role ?? i.type ?? i.label),
|
||||
["undo", "redo", "separator", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll", "separator", "Substitutions", "Speech"]);
|
||||
assert.deepEqual(applicationMenuTemplate("zh").map((i) => i.role ?? "edit"), ["appMenu", "edit", "windowMenu"]);
|
||||
});
|
||||
|
||||
test("the application menu is rebuilt when the language changes and not otherwise", () => {
|
||||
const built = [];
|
||||
const Menu = {
|
||||
buildFromTemplate: (t) => (built.push(t), { template: t }),
|
||||
setApplicationMenu: () => {},
|
||||
getApplicationMenu: () => null,
|
||||
};
|
||||
const install = menuInstaller(Menu, "darwin");
|
||||
let lang = "en";
|
||||
install(() => lang);
|
||||
install(() => lang);
|
||||
assert.equal(built.length, 1);
|
||||
lang = "zh";
|
||||
install(() => lang);
|
||||
assert.equal(built.length, 2);
|
||||
assert.equal(built[1][1].label, "编辑");
|
||||
let cleared = 0;
|
||||
menuInstaller({ ...Menu, setApplicationMenu: () => cleared++ }, "linux")(() => "zh");
|
||||
assert.equal(cleared, 1);
|
||||
assert.equal(built.length, 2);
|
||||
});
|
||||
|
||||
test("the interface language is the page's own choice, else the machine's", () => {
|
||||
assert.equal(uiLanguage({ "rx-lang": "zh" }, "en-US"), "zh");
|
||||
assert.equal(uiLanguage({ "rx-lang": "en" }, "zh-CN"), "en");
|
||||
assert.equal(uiLanguage({ "rx-lang": "" }, "zh-Hans-CN"), "zh");
|
||||
assert.equal(uiLanguage({}, "fr-FR"), "en");
|
||||
assert.equal(uiLanguage(undefined, undefined), "en");
|
||||
});
|
||||
|
||||
test("a saved preference tells the shell so it can follow it", () => {
|
||||
const handlers = {};
|
||||
const ipc = { on: (name, fn) => { handlers[name] = fn; } };
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-prefs-"));
|
||||
let saved = 0;
|
||||
registerPrefs(ipc, () => path.join(dir, "p.json"), () => true, () => { saved++; });
|
||||
handlers["prefs:save"]({ returnValue: null }, { "rx-lang": "zh" });
|
||||
assert.equal(saved, 1);
|
||||
});
|
||||
|
||||
test("only http and https ever reach the platform opener", () => {
|
||||
assert.equal(externalTarget("https://example.test/a"), "https://example.test/a");
|
||||
assert.equal(externalTarget("http://example.test/"), "http://example.test/");
|
||||
@@ -124,7 +187,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
|
||||
assert.equal(await dead.trayState(), null);
|
||||
});
|
||||
|
||||
const { reveal } = require("../src/reveal.js");
|
||||
const { reveal, revealWorkspace } = require("../src/reveal.js");
|
||||
|
||||
// A kernel that answers /workspace/locate as the test says, and a shell that
|
||||
// only records what it was asked to open.
|
||||
@@ -143,7 +206,7 @@ async function revealRig(answer, platform = process.platform) {
|
||||
openPath: async (p) => (opened.push(["open", p]), ""),
|
||||
showItemInFolder: (p) => opened.push(["select", p]),
|
||||
};
|
||||
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), close: () => server.close() };
|
||||
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), workspace: (root) => revealWorkspace(client, shell, root, platform), close: () => server.close() };
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
|
||||
@@ -194,6 +257,22 @@ test("the page cannot steer reveal to a location the kernel did not name", async
|
||||
}
|
||||
});
|
||||
|
||||
test("a listed project is shown through the hub and only on its answer", async () => {
|
||||
const rig = await revealRig((url) =>
|
||||
url.pathname === "/host/workspaces/locate" && url.searchParams.get("root") === "/work/a b"
|
||||
? [200, { path: path.join(ROOT, "a b"), dir: true }]
|
||||
: [404, { code: "workspace.not_listed", error: "not listed" }],
|
||||
);
|
||||
try {
|
||||
assert.equal(await rig.workspace("/work/a b"), null);
|
||||
assert.equal((await rig.workspace("/etc")).code, "workspace.not_listed");
|
||||
assert.deepEqual(rig.opened, [["select", path.join(ROOT, "a b")]]);
|
||||
assert.equal(rig.asked[0], "/host/workspaces/locate?root=%2Fwork%2Fa%20b");
|
||||
} finally {
|
||||
rig.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A root that is an .app bundle, or a link to one, is a directory to stat and
|
||||
// an application to `open`: handing it to openPath would launch it.
|
||||
test("the workspace root is selected, never opened", async () => {
|
||||
@@ -314,6 +393,79 @@ test("a kernel that cannot be started says so, rather than going quiet", async (
|
||||
});
|
||||
});
|
||||
|
||||
test("a launch failure carries a typed cause, and a slow kernel is waited for, not restarted", async () => {
|
||||
const { start } = require("../src/host.js");
|
||||
const node = process.execPath;
|
||||
let slow = 0;
|
||||
const silent = start(node, ["-e", "setTimeout(()=>{},5000)"], { timeoutMs: 400, onSlow: () => slow++ });
|
||||
await assert.rejects(silent.ready, (err) => {
|
||||
assert.equal(err.code, "timeout");
|
||||
assert.match(err.message, /still running/);
|
||||
return true;
|
||||
});
|
||||
silent.child.kill();
|
||||
assert.equal(slow, 1, "the slow state was not reported once");
|
||||
|
||||
const quick = start(node, ["-e", "process.exit(7)"]);
|
||||
await assert.rejects(quick.ready, (err) => err.code === "exited" && err.exitCode === 7 && /exited with 7/.test(err.message));
|
||||
|
||||
const missing = start(path.join(os.tmpdir(), "reasonix-no-such-kernel-c9"), []);
|
||||
await assert.rejects(missing.ready, (err) => err.code === "spawn");
|
||||
|
||||
const handshake = JSON.stringify({ version: 1, origin: "http://127.0.0.1:1", token: "x".repeat(32) });
|
||||
const fine = start(node, ["-e", `console.log(${JSON.stringify(handshake)});setTimeout(()=>{},300)`], { timeoutMs: 400 });
|
||||
assert.equal((await fine.ready).origin, "http://127.0.0.1:1");
|
||||
});
|
||||
|
||||
test("a retry needs an early stop, is capped at one, and never follows a timeout", () => {
|
||||
const { shouldRetry } = require("../src/host.js");
|
||||
const exited = { code: "exited" };
|
||||
assert.equal(shouldRetry(exited, 1, 500), true);
|
||||
assert.equal(shouldRetry({ code: "spawn" }, 1, 500), true);
|
||||
assert.equal(shouldRetry(exited, 1, 10001), false, "an exit after the early window was retried");
|
||||
assert.equal(shouldRetry(exited, 2, 500), false, "a second retry was allowed");
|
||||
assert.equal(shouldRetry({ code: "timeout" }, 1, 500), false);
|
||||
assert.equal(shouldRetry(new Error("x"), 1, 500), false);
|
||||
});
|
||||
|
||||
test("a handshake that does not parse is a typed failure", async () => {
|
||||
const { start } = require("../src/host.js");
|
||||
const bad = start(process.execPath, ["-e", "console.log('hello')"]);
|
||||
await assert.rejects(bad.ready, (err) => err.code === "malformed");
|
||||
});
|
||||
|
||||
test("a kernel that never answers is started once and not retried", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-silent-"));
|
||||
const host = path.join(dir, "host.sh");
|
||||
fs.writeFileSync(host, "#!/bin/sh\nexec sleep 5\n", { mode: 0o755 });
|
||||
process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS = "400";
|
||||
const shell = loadShell({ lock: true, host });
|
||||
try {
|
||||
await shell.quitted;
|
||||
const log = shell.shellLog();
|
||||
assert.equal((log.match(/host: starting /g) || []).length, 1);
|
||||
assert.doesNotMatch(log, /retrying once/);
|
||||
assert.match(shell.calls[0][2], /still running/);
|
||||
} finally {
|
||||
delete process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS;
|
||||
shell.cleanup();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("each failure cause gets its own explanation in both languages", () => {
|
||||
const { startupFailure } = require("../src/shelllog.js");
|
||||
const seen = new Set();
|
||||
for (const code of ["timeout", "exited", "spawn"]) {
|
||||
for (const locale of ["en", "zh-CN"]) {
|
||||
const { detail } = startupFailure(locale, "r", "/logs", code);
|
||||
assert.ok(!seen.has(detail), `${code}/${locale} repeated another cause's text`);
|
||||
seen.add(detail);
|
||||
}
|
||||
}
|
||||
assert.equal(startupFailure("en", "r", "/logs", "overflow").detail, "r\n\nLogs are in:\n/logs");
|
||||
});
|
||||
|
||||
// A fake child: only the stdout half readActs touches, and a way to push bytes
|
||||
// through it in whatever chunks the test wants -- which is the point, since the
|
||||
// bug this guards is a line split across two of them.
|
||||
@@ -1104,6 +1256,7 @@ function loadShell({ lock, host }) {
|
||||
if (key === "getPath") return () => userData;
|
||||
if (key === "getVersion") return () => "9.9.9";
|
||||
if (key === "getLocale") return () => "en-US";
|
||||
if (key === "getPreferredSystemLanguages") return () => ["en-US"];
|
||||
if (key === "isPackaged") return false;
|
||||
if (key === "quit") return () => { calls.push(["quit"]); quit(); };
|
||||
return inert;
|
||||
@@ -1163,8 +1316,29 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
|
||||
assert.match(shell.hostLog(), /bad option/, "the host's stderr never reached host.log");
|
||||
const log = shell.shellLog();
|
||||
assert.match(log, /host: exited code=9 signal=null before its handshake/);
|
||||
assert.match(log, /startup failed: Error: /);
|
||||
assert.match(log, /startup failed: HostStartError: /);
|
||||
assert.equal((log.match(/host: starting /g) || []).length, 2, "an early exit was not retried exactly once");
|
||||
assert.match(log, /retrying once/);
|
||||
assert.match(detail, /after 2 attempts/);
|
||||
assert.match(detail, /stopped before it was ready/, "no cause was given for an early exit");
|
||||
} finally {
|
||||
shell.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
|
||||
const { trayAsset, SCALES } = require("../src/trayimage.js");
|
||||
const dim = (f) => {
|
||||
const b = fs.readFileSync(f);
|
||||
return [b.readUInt32BE(16), b.readUInt32BE(20)];
|
||||
};
|
||||
for (const [scale] of SCALES) {
|
||||
const { file, pixels } = trayAsset(scale);
|
||||
assert.deepEqual(dim(file), [pixels, pixels], file);
|
||||
assert.equal(pixels, Math.round(16 * scale));
|
||||
}
|
||||
assert.equal(trayAsset(1.75).pixels, 28);
|
||||
assert.equal(trayAsset(1.8).pixels, 32);
|
||||
assert.equal(trayAsset(5).pixels, 48);
|
||||
assert.equal(trayAsset(NaN).pixels, 16);
|
||||
});
|
||||
@@ -46,6 +46,7 @@ node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下
|
||||
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
|
||||
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
|
||||
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
|
||||
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
|
||||
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
|
||||
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
|
||||
node perf/locale.mjs # 跟随系统语言:中文各写法都归中文,其余归英文
|
||||
|
||||
@@ -4,6 +4,7 @@ import { track as trackWidth } from "../src/ui/viewport";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/app.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/feedback.css";
|
||||
import { App } from "../src/ui/App";
|
||||
import { MockHub } from "../src/port/mock_hub";
|
||||
import { MockPort } from "../src/port/mock";
|
||||
@@ -44,11 +45,12 @@ class BenchPort extends MockPort {
|
||||
}
|
||||
|
||||
async providerSetup() {
|
||||
return null;
|
||||
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
|
||||
}
|
||||
|
||||
async appearance() {
|
||||
const look = await super.appearance();
|
||||
let look = await super.appearance();
|
||||
if (query.has("zoom")) look = { ...look, zoom: Number(query.get("zoom")) };
|
||||
return PREF === null ? look : { ...look, language: PREF };
|
||||
}
|
||||
|
||||
@@ -148,6 +150,7 @@ const WORKSPACES = Number(query.get("ws") ?? 0);
|
||||
// ?pref= 是「内核记着的语言」。真机上它来自 config;这里由地址给,好让
|
||||
// 一次验证能把两侧摆成同一个值——否则 adopt 会认为本地缓存过期。
|
||||
const PREF = query.get("pref");
|
||||
const ONBOARDING = query.has("onboarding");
|
||||
const SESSIONS = Number(query.get("sess") ?? 0);
|
||||
// ?turns= is how long the conversation being opened already is.
|
||||
const TURNS = Number(query.get("turns") ?? 0);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// The first-run "start" button must be reachable at every window height.
|
||||
// The body clips, so a card taller than the window is only reachable if the
|
||||
// stage itself scrolls. Focus is no evidence: the browser scrolls the clipped
|
||||
// body to a focused element, so the wheel is what gets asserted.
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?onboarding=1";
|
||||
const SHOTS = process.env.ONBOARDING_SHOTS;
|
||||
const SIZES = [
|
||||
[1920, 700], [1600, 600], [2560, 560], [1366, 600], [1280, 540], [1440, 900], [1600, 760, 1.25],
|
||||
[760, 480], [761, 480], [760, 480, 0.8], [761, 480, 1.8], [800, 480, 1.8], [840, 560, 1.8],
|
||||
[840, 560, 1.5], [1280, 720, 1.8], [1600, 900, 1.8, [840, 560]], [840, 560, 1.8, [1600, 900]],
|
||||
];
|
||||
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, height, zoom = 1, resize] of SIZES) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
const url = new URL(PAGE);
|
||||
url.searchParams.set("zoom", String(zoom));
|
||||
await page.goto(url.href, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".onb-card");
|
||||
await page.waitForFunction((z) => Number(getComputedStyle(document.documentElement).zoom) === z, zoom);
|
||||
const tag = `${scheme} ${width}x${height}${zoom !== 1 ? ` zoom${zoom}` : ""}${resize ? ` to ${resize.join("x")}` : ""}`;
|
||||
|
||||
const fitsCard = async (label) => {
|
||||
const layout = await page.evaluate(() => {
|
||||
const stage = document.querySelector(".onb-stage");
|
||||
const shell = document.querySelector(".onb-shell");
|
||||
const r = shell.getBoundingClientRect();
|
||||
const fields = ["#onb-url", "#onb-key", ".onb-go", ".onb-note", ".onb-protocols", ".onb-pick"];
|
||||
const clipped = fields.flatMap((selector) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return [];
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.left < Math.max(0, r.left) - 0.5 || box.right > Math.min(innerWidth, r.right) + 0.5
|
||||
? [{ selector, left: box.left, right: box.right, shellRight: r.right }] : [];
|
||||
});
|
||||
return { clipped, columns: getComputedStyle(shell).gridTemplateColumns.split(" ").length,
|
||||
expectedColumns: stage.clientWidth <= 760 ? 1 : 2, width: stage.scrollWidth, client: stage.clientWidth };
|
||||
});
|
||||
check(`${tag} ${label}: form stays within the card and viewport`, layout.clipped.length === 0, JSON.stringify(layout.clipped));
|
||||
check(`${tag} ${label}: columns follow zoom-adjusted room`, layout.columns === layout.expectedColumns, JSON.stringify(layout));
|
||||
check(`${tag} ${label}: stage does not scroll sideways`, layout.width <= layout.client + 1);
|
||||
};
|
||||
|
||||
const reachable = async (label, enabled) => {
|
||||
const viewport = page.viewportSize();
|
||||
const go = page.locator(".onb-go");
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.mouse.move(viewport.width / 2, viewport.height / 2);
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await page.waitForTimeout(250);
|
||||
const box = await go.boundingBox();
|
||||
check(`${tag} ${label}: after wheeling to the bottom the button is in the viewport`, !!box && box.y >= 0 && box.y + box.height <= viewport.height + 0.5, JSON.stringify(box));
|
||||
const bar = await page.locator(".onb-brandbar").boundingBox();
|
||||
check(`${tag} ${label}: the title bar stays in the viewport`, !!bar && bar.y >= -0.5 && bar.y + bar.height <= viewport.height + 0.5, JSON.stringify(bar));
|
||||
if (!enabled) return;
|
||||
const hit = await page.evaluate(() => {
|
||||
const r = document.querySelector(".onb-go").getBoundingClientRect();
|
||||
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
||||
return !!el?.closest(".onb-go");
|
||||
});
|
||||
check(`${tag} ${label}: the point under its centre hits it`, hit);
|
||||
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${tag.replaceAll(" ", "-")}-${label.replaceAll(" ", "-")}.png`) });
|
||||
const first = page.locator(".onb-chip").first();
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await first.focus();
|
||||
await page.waitForTimeout(250);
|
||||
const [fb, bb] = [await first.boundingBox(), await page.locator(".onb-brandbar").boundingBox()];
|
||||
check(`${tag} ${label}: a focused top-of-card element sits below the title bar`, !!fb && !!bb && fb.y >= bb.y + bb.height - 0.5, JSON.stringify({ fb, bb }));
|
||||
};
|
||||
|
||||
await fitsCard("before connect");
|
||||
await reachable("before connect", false);
|
||||
await page.fill('input[placeholder="sk-…"]', "sk-test");
|
||||
await page.locator(".onb-go").click();
|
||||
await page.waitForSelector(".onb-found");
|
||||
await fitsCard("after connect");
|
||||
await reachable("after connect", true);
|
||||
if (resize) {
|
||||
await page.setViewportSize({ width: resize[0], height: resize[1] });
|
||||
await page.waitForFunction(() => {
|
||||
const stage = document.querySelector(".onb-stage");
|
||||
return Math.abs(stage.getBoundingClientRect().width - innerWidth) < 1;
|
||||
});
|
||||
await fitsCard("after resize");
|
||||
await reachable("after resize", true);
|
||||
}
|
||||
await page.locator(".onb-go").click();
|
||||
await page.waitForSelector(".app .chrome");
|
||||
check(`${tag}: Start finishes setup`, await page.locator(".onb-stage").count() === 0);
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log(fails.length ? `\n${fails.length} failed` : "\nall passed");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -95,6 +95,10 @@ export const ACTIONS: UIAction[] = [
|
||||
|
||||
{ id: "tool.image-open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "tool.image-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "tool.show-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "agent.open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "agent.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "agent.close", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
// ── The turn ─────────────────────────────────────────────────────────────
|
||||
// Send and stop remain two actions even when they sit together: during a
|
||||
@@ -151,6 +155,37 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "community.join", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.discord", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.issues", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.contributors", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.profile", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "community.douyin-qr", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "community.author", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
|
||||
@@ -196,6 +231,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "market.sort", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.pinned", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.show-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.retry", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.detail-retry", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "market.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -215,8 +252,11 @@ export const ACTIONS: UIAction[] = [
|
||||
// The account's own packages: a preview, an install pinned to that preview's
|
||||
// digest, and sending a private one to review.
|
||||
{ id: "market.own-inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.own-retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.mine-retry", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.submit", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.vote", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.vote-retry", kind: "view", target: "none", proof: "interaction" },
|
||||
// Taking back the innermost open thing: a popover, an inline form. One
|
||||
// intent, reached by pressing away and by Escape.
|
||||
{ id: "layer.dismiss", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -254,6 +294,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// state — which is why it is one id and why it is not a mutation.
|
||||
{ id: "external.open", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "remote-host.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.reveal", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workspace.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "extensions.invoke", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -268,6 +309,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "remote.probe", kind: "repeatable", target: "entity", proof: "authority-effect" },
|
||||
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
|
||||
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
|
||||
// ── Taking a change back ─────────────────────────────────────────────────
|
||||
// Planning a revert and performing one are two contracts: the first asks the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="296" class="segno"><g transform="scale(8)"><path fill="#fff" d="M0 0h37v37h-37z"/><path class="qrline" stroke="#000" d="M4 4.5h7m2 0h1m1 0h1m2 0h1m2 0h1m1 0h2m1 0h7m-29 1h1m5 0h1m5 0h1m3 0h2m2 0h1m1 0h1m5 0h1m-29 1h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m2 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m6 0h1m2 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m11 0h1m1 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m5 0h1m1 0h5m2 0h3m1 0h1m2 0h1m5 0h1m-29 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-20 1h1m1 0h3m1 0h3m-18 1h1m2 0h1m1 0h2m1 0h1m1 0h2m2 0h1m1 0h1m1 0h2m1 0h1m1 0h1m-21 1h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h4m1 0h1m1 0h1m2 0h1m2 0h1m-29 1h4m1 0h4m1 0h2m1 0h3m2 0h2m3 0h5m-28 1h4m4 0h1m3 0h2m2 0h1m9 0h2m-28 1h2m2 0h1m1 0h1m1 0h5m1 0h2m1 0h1m1 0h2m1 0h2m1 0h1m1 0h2m-28 1h1m1 0h2m3 0h1m2 0h1m1 0h4m1 0h2m1 0h1m-22 1h2m1 0h2m1 0h1m1 0h1m1 0h1m1 0h3m3 0h1m1 0h2m1 0h1m1 0h4m-28 1h2m1 0h2m2 0h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h2m1 0h2m1 0h1m-28 1h4m2 0h2m1 0h3m1 0h1m3 0h3m1 0h1m5 0h1m-27 1h2m2 0h1m1 0h1m2 0h1m1 0h5m1 0h1m2 0h2m2 0h1m2 0h1m-29 1h1m1 0h2m1 0h6m3 0h5m3 0h3m2 0h2m-27 1h2m4 0h1m1 0h2m2 0h1m2 0h1m1 0h2m3 0h1m2 0h2m-29 1h1m2 0h1m2 0h1m2 0h1m5 0h3m1 0h6m1 0h1m-19 1h2m1 0h2m1 0h2m1 0h4m3 0h1m1 0h3m-29 1h7m2 0h1m2 0h1m4 0h2m1 0h1m1 0h1m1 0h1m2 0h1m-28 1h1m5 0h1m1 0h2m1 0h1m2 0h2m3 0h2m3 0h5m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m3 0h3m1 0h5m2 0h1m-28 1h1m1 0h3m1 0h1m1 0h2m2 0h2m1 0h2m1 0h2m2 0h5m1 0h1m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m1 0h1m2 0h4m3 0h3m1 0h1m-29 1h1m5 0h1m2 0h1m2 0h1m1 0h1m2 0h1m1 0h2m1 0h1m4 0h1m-28 1h7m1 0h1m3 0h2m2 0h2m2 0h3m1 0h1m2 0h1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,141 @@
|
||||
[
|
||||
"SivanCola",
|
||||
"KHG420",
|
||||
"ttmouse",
|
||||
"lifu963",
|
||||
"HUQIANTAO",
|
||||
"GTC2080",
|
||||
"mchenziyi",
|
||||
"Li-Charles-One",
|
||||
"light-front-theory",
|
||||
"eghrhegpe",
|
||||
"Bernardxu123",
|
||||
"HaoyueQin",
|
||||
"BuGlessRB",
|
||||
"chuiwenwei",
|
||||
"CVEngineer66",
|
||||
"JesonChou",
|
||||
"lanshi17",
|
||||
"SuMuxi66",
|
||||
"paradoxSCH",
|
||||
"CnsMaple",
|
||||
"cyq1017",
|
||||
"Guan0923",
|
||||
"ipanta",
|
||||
"XTLine",
|
||||
"YoungDan-hero",
|
||||
"erphenheimer",
|
||||
"SauronSkywalker",
|
||||
"ashishexee",
|
||||
"lizhengwu",
|
||||
"par73e",
|
||||
"yizeyi18",
|
||||
"Nath-Vikky",
|
||||
"PMKang",
|
||||
"qiaone",
|
||||
"ferstar",
|
||||
"Harbor404",
|
||||
"liaoyl830",
|
||||
"Lxiny-zy",
|
||||
"zhangsanouo",
|
||||
"AtmoOmen",
|
||||
"codex",
|
||||
"Diarica",
|
||||
"ffff2004",
|
||||
"ikelvingo",
|
||||
"kekexunxun",
|
||||
"lightfront",
|
||||
"maoxin1234",
|
||||
"nostalgia296",
|
||||
"onionviolet",
|
||||
"PaulDing98",
|
||||
"scrappylabsai",
|
||||
"skyzhao1223",
|
||||
"Tatlatat",
|
||||
"yekern",
|
||||
"assoyuan",
|
||||
"billycat",
|
||||
"boscocp",
|
||||
"closss",
|
||||
"drafish",
|
||||
"HBLADEH",
|
||||
"heshuimu",
|
||||
"iysun",
|
||||
"jackie-cqz",
|
||||
"LVGG",
|
||||
"nanami7777777",
|
||||
"qiukeren",
|
||||
"sudoviber",
|
||||
"warvyvr",
|
||||
"whale-fall-ouo",
|
||||
"wqshan-cn",
|
||||
"xianyu-sheng",
|
||||
"Xio-Shark",
|
||||
"xxoingr",
|
||||
"yhemeo613",
|
||||
"zdjmrq",
|
||||
"Abysstsuki",
|
||||
"ahmedalbusaidy",
|
||||
"Akashmdshetty",
|
||||
"alxbnct",
|
||||
"c020627",
|
||||
"chengoak",
|
||||
"CooperSheroy",
|
||||
"dcs02280653-collab",
|
||||
"dengbushi",
|
||||
"EMEEEEMMMM",
|
||||
"ERroooooR",
|
||||
"gcoder1991",
|
||||
"h4rk8s",
|
||||
"HHHEEEWWW",
|
||||
"hobostay",
|
||||
"HorusJiang",
|
||||
"i1j",
|
||||
"innocarpe",
|
||||
"j2bap",
|
||||
"jackijianxa",
|
||||
"jbts6",
|
||||
"ji-tz",
|
||||
"JimmyDaddy",
|
||||
"kabaka9527",
|
||||
"kate1128",
|
||||
"LegGasai",
|
||||
"leslie3747",
|
||||
"LouF666",
|
||||
"MagnumGoYB",
|
||||
"MaitreyeeDeshmukh",
|
||||
"myipanta",
|
||||
"nanami-0713",
|
||||
"Ninot1Quyi",
|
||||
"NkAntony777",
|
||||
"no-name-touse",
|
||||
"Nontee22",
|
||||
"onenewcode",
|
||||
"Oxygen56",
|
||||
"PorunC",
|
||||
"Q1hangL",
|
||||
"QingLong-C",
|
||||
"Ray-lyy",
|
||||
"rixzkiye",
|
||||
"Robertoarce",
|
||||
"ryukeilee",
|
||||
"seankhliao",
|
||||
"ShiroKSH",
|
||||
"siwenyu666-beep",
|
||||
"SprayHank",
|
||||
"Starry0214",
|
||||
"Surfire",
|
||||
"taibai233",
|
||||
"themarchrain",
|
||||
"tic-top",
|
||||
"weikengchen",
|
||||
"wudiandy",
|
||||
"xiaoshuai1024",
|
||||
"xiaotaoya2022-cell",
|
||||
"xinrenH",
|
||||
"ying-hua",
|
||||
"yuanchenglu",
|
||||
"zhuguang-ZFG",
|
||||
"Zhuocheng-Lang",
|
||||
"zlwu"
|
||||
]
|
||||
@@ -16,16 +16,20 @@ import { EN_STORAGE } from "./en_storage";
|
||||
import { EN_USAGE } from "./en_usage";
|
||||
import { EN_COMPOSER } from "./en_composer";
|
||||
import { EN_BACKUP } from "./en_backup";
|
||||
import { EN_FEEDBACK } from "./en_feedback";
|
||||
import { EN_COMMUNITY } from "./en_community";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
"工作台": "Workbench",
|
||||
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
|
||||
...EN_SETTINGS,
|
||||
...EN_COMMUNITY,
|
||||
...EN_METRICS,
|
||||
...EN_STORAGE,
|
||||
...EN_USAGE,
|
||||
...EN_COMPOSER,
|
||||
...EN_BACKUP,
|
||||
...EN_FEEDBACK,
|
||||
...EN_KERNEL,
|
||||
...EN_REMOTE,
|
||||
...EN_WINDOW,
|
||||
@@ -67,6 +71,7 @@ export const EN: Record<string, string> = {
|
||||
"复制": "Copy",
|
||||
"已复制": "Copied",
|
||||
"复制不了": "Copy blocked",
|
||||
"复制失败,请重试": "Copy failed. Try again.",
|
||||
"复制这段回答": "Copy this answer",
|
||||
"复制这段代码": "Copy this code",
|
||||
"复制路径": "Copy path",
|
||||
@@ -276,6 +281,10 @@ export const EN: Record<string, string> = {
|
||||
"尚无文件夹": "No folders yet",
|
||||
"正在读取文件夹…": "Reading folders…",
|
||||
"打开项目…": "Open a project…",
|
||||
"添加工作区": "Add a workspace",
|
||||
"无法打开文件夹选择器。请输入内核所在机器上的路径。": "The folder picker cannot open. Enter a path on the machine running the kernel.",
|
||||
"工作区路径": "Workspace path",
|
||||
"此内核不支持添加工作区。": "This kernel does not support adding workspaces.",
|
||||
"先打开一个项目": "Open a project first",
|
||||
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
|
||||
"使用当前位置": "Stay where I am",
|
||||
@@ -331,6 +340,11 @@ export const EN: Record<string, string> = {
|
||||
"正在运行,暂停后才能关闭": "Running; pause it before closing",
|
||||
"受阻": "blocked",
|
||||
"状态不明": "uncertain",
|
||||
"这条插话在本轮结束前没来得及送达,没有发给模型": "This guidance was not delivered before the turn ended, so the model never saw it",
|
||||
"本轮已结束,但对话记录没能保存": "The turn ended, but the transcript could not be saved",
|
||||
"本轮已结束,但队列没能确认这一条": "The turn ended, but the queue could not acknowledge this item",
|
||||
"运行这一条的会话已不在了": "The session that was running this item is gone",
|
||||
"队列记录损坏,这一条是从残留文件里找回的": "The queue record was corrupt; this item was salvaged from leftover files",
|
||||
"冻结 {n} 文件": "{n} frozen",
|
||||
"无法读取该条的正文,未打开编辑:{why}":
|
||||
"Could not read this entry back, so it was not opened for editing: {why}",
|
||||
@@ -1090,6 +1104,8 @@ export const EN: Record<string, string> = {
|
||||
"项目操作:{name}": "Project actions: {name}",
|
||||
"项目操作": "Project actions",
|
||||
"不删除文件": "Keeps files",
|
||||
"在文件管理器中显示": "Show in file manager",
|
||||
"文件夹已不在磁盘上": "Folder is gone",
|
||||
"删除会话:{title}": "Delete session: {title}",
|
||||
"会话在文件夹里打开,先添加一个": "A session opens in a folder; add one first",
|
||||
"添加文件夹": "Add folder",
|
||||
@@ -1213,5 +1229,7 @@ export const EN: Record<string, string> = {
|
||||
"同一段内容被逐字重复": "The same block is being repeated verbatim",
|
||||
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
|
||||
"等待你的输入": "Waiting for you",
|
||||
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
|
||||
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
|
||||
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export const EN_COMMUNITY: Record<string, string> = {
|
||||
"社区与贡献者": "Community and contributors",
|
||||
"遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。": "Join the community for help, tips and ideas. Below are the people who have contributed code to Reasonix.",
|
||||
"QQ 群二维码": "QQ group QR code",
|
||||
"群号": "Group number",
|
||||
"复制群号": "Copy group number",
|
||||
"用 QQ 扫描二维码,或点击下方按钮加入。": "Scan the QR code with QQ, or use the button below to join.",
|
||||
"加入 QQ 群": "Join the QQ group",
|
||||
"贡献者": "Contributors",
|
||||
"按提交数排序,名单随版本更新": "Sorted by commits; the list is refreshed with each release",
|
||||
"查看全部贡献者": "View all contributors",
|
||||
"显示全部 {n} 位": "Show all {n}",
|
||||
"抖音": "Douyin",
|
||||
"抖音号": "Douyin ID",
|
||||
"复制抖音号": "Copy Douyin ID",
|
||||
"显示二维码": "Show QR code",
|
||||
"收起二维码": "Hide QR code",
|
||||
"抖音二维码": "Douyin QR code",
|
||||
"作者与维护者": "Author and maintainer",
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"正在加载命令与技能…": "Loading commands and skills…",
|
||||
"引用文件": "Reference files",
|
||||
"Enter 插话 · Shift+Enter 换行": "Enter to steer · Shift+Enter for a new line",
|
||||
"点按插话 · 回车换行": "Tap Steer · Enter for a new line",
|
||||
"{n} 字 · {lines} 行": "{n} characters · {lines} lines",
|
||||
"知道了": "Got it",
|
||||
"是否信任此文件夹": "Trust this folder",
|
||||
@@ -66,6 +67,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"Nobody has said whether this model reads images · treated as no for now; tick it under Connection to send them straight through",
|
||||
"自动": "Auto",
|
||||
"Enter 发送 · Shift+Enter 换行": "Enter to send · Shift+Enter for a new line",
|
||||
"点按发送 · 回车换行": "Tap Send · Enter for a new line",
|
||||
"不询问": "Do not ask",
|
||||
"全部放行": "Allow all",
|
||||
"均衡": "Balanced",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// English for the in-app feedback dialog and its refusals.
|
||||
export const EN_FEEDBACK: Record<string, string> = {
|
||||
"反馈": "Feedback",
|
||||
"已完成:": "Done: ",
|
||||
"当前:": "Current: ",
|
||||
"尚未开始:": "Not yet: ",
|
||||
"发送反馈": "Send feedback",
|
||||
"我的反馈": "My feedback",
|
||||
"问题": "Bug",
|
||||
"建议": "Idea",
|
||||
"疑问": "Question",
|
||||
"其他": "Other",
|
||||
"已收到": "Received",
|
||||
"已登记": "Recorded",
|
||||
"处理中": "In progress",
|
||||
"已修复": "Fixed",
|
||||
"不予修复": "Won't fix",
|
||||
"重复": "Duplicate",
|
||||
"已登记为 {issue}": "Recorded as {issue}",
|
||||
"已在 {version} 修复": "Fixed in {version}",
|
||||
"已修复,将随下个版本发布": "Fixed, ships in the next version",
|
||||
"与 {issue} 重复": "Duplicate of {issue}",
|
||||
"已解决": "Resolved",
|
||||
"有些反馈是用这台电脑以前的身份发出的,已经查不到它们的最新状态。": "Some reports were sent under this computer's earlier identity, so their current status can no longer be read.",
|
||||
"状态已无法追踪": "Status no longer trackable",
|
||||
"处理进展": "Progress",
|
||||
"刷新列表": "Refresh list",
|
||||
"每次打开这一页时刷新。状态和回复来自维护者,已登记的反馈会跟随对应的 GitHub 议题。":
|
||||
"Refreshed each time you open this page. Status and replies come from the maintainers; a report that was filed follows its GitHub issue.",
|
||||
"暂时连不上反馈服务。下面是保存在本机的记录,状态可能不是最新的。":
|
||||
"Cannot reach the feedback service right now. This is what was saved on this machine, and statuses may be out of date.",
|
||||
"正在读取你的反馈…": "Loading your feedback…",
|
||||
"还没有提交过反馈。发出第一条之后,它的进展会出现在这里。": "No feedback sent yet. Once you send one, its progress shows up here.",
|
||||
"复制回执号": "Copy receipt number",
|
||||
"回执号": "Receipt number",
|
||||
"已收到你的反馈": "Your feedback was received",
|
||||
"回执号是这份反馈的凭据,请留着它。我们会先看一遍,只有被登记为 GitHub 议题的内容才会公开。进展、议题链接,以及我们的回复或提问,都会出现在「我的反馈」里。":
|
||||
"Keep the receipt number: it identifies this report. We read it first, and only what we file as a GitHub issue becomes public. Progress, the issue link and any reply or question from us appear under My feedback.",
|
||||
"你的文字里有看起来像密钥的内容,已在发出前打码。": "Text in your report that looked like a secret was masked before it was sent.",
|
||||
"查看我的反馈": "View my feedback",
|
||||
"再写一条": "Write another",
|
||||
"只有被我们登记为议题的反馈才会公开":
|
||||
"It becomes public only if we file it as an issue",
|
||||
"如果登记为 GitHub 议题,你的文字、昵称和截图会发布在 esengine/DeepSeek-Reasonix 仓库,任何人都看得到;不登记就不会公开。你会在这里收到回执,登记后还会看到议题链接,我们也可能在这里回复或向你提问。":
|
||||
"If we file it as a GitHub issue, your text, nickname and screenshots are posted in the esengine/DeepSeek-Reasonix repository, where anyone can read them; if we do not, it stays private. You will see a receipt here and, once filed, the issue link, and we may reply or ask you questions here.",
|
||||
"请不要在文字或截图里写入密钥、账号密码、聊天记录或私有代码:一旦公开就收不回来。":
|
||||
"Do not put secrets, passwords, chats or private code in the text or in screenshots: once public it cannot be taken back.",
|
||||
"发现的是安全漏洞?请不要在这里提交,{link}。": "Found a security vulnerability? Do not post it here; {link}.",
|
||||
"按安全策略私下报告": "report it privately under the security policy",
|
||||
"反馈内容": "Feedback details",
|
||||
"反馈类别": "Feedback category",
|
||||
"发生了什么?": "What happened?",
|
||||
"描述你遇到的情况,或你希望它怎样。": "Describe what you ran into, or what you would like instead.",
|
||||
"{n} / {max} 字节": "{n} / {max} bytes",
|
||||
"昵称(登记为议题时公开)": "Nickname (public if filed as an issue)",
|
||||
"如果登记为议题,会显示为「由 {name} 提交」。用昵称即可,不必真名。":
|
||||
"If it is filed as an issue it shows as \"Reported by {name}\". A nickname is fine; no real name needed.",
|
||||
"联系方式(可选,私密)": "Contact (optional, private)",
|
||||
"邮箱或 QQ。只有维护者看得到,绝不会出现在 GitHub 上。": "Email or QQ. Only the maintainers see it; it is never posted on GitHub.",
|
||||
"截图(可选,登记为议题时公开)":
|
||||
"Screenshots (optional, public if filed as an issue)",
|
||||
"移除截图 {name}": "Remove screenshot {name}",
|
||||
"添加截图…": "Add screenshots…",
|
||||
"选择截图文件": "Choose screenshot files",
|
||||
"也可以直接粘贴或拖进来。PNG 或 JPEG,最多 {n} 张,每张不超过 {size} MB,较大的图会自动缩小。":
|
||||
"You can also paste or drop them here. PNG or JPEG, up to {n} images, {size} MB each; large images are shrunk automatically.",
|
||||
"如果这份反馈被登记为公开议题,截图会和文字一起公开。发送前请确认没有露出密钥、聊天记录或私有代码。":
|
||||
"If this report is filed as a public issue, your screenshots are published with your text. Before sending, check they show no keys, chats or private code.",
|
||||
"{name}:只支持 PNG 或 JPEG": "{name}: only PNG or JPEG is supported",
|
||||
"{name}:超过 {size} MB,没有添加": "{name}: larger than {size} MB, not added",
|
||||
"{name}:最多 {n} 张,没有添加": "{name}: the limit is {n} images, not added",
|
||||
"随反馈一起发送的环境信息": "Environment sent with your report",
|
||||
"入口": "Surface",
|
||||
"渠道": "Channel",
|
||||
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
|
||||
"去 GitHub": "Go to GitHub",
|
||||
"重试发送": "Retry sending",
|
||||
"正文不能为空,请写下你遇到的情况。": "The text is empty. Please describe what happened.",
|
||||
"正文太长了,请缩短后再发。": "The text is too long. Please shorten it and send again.",
|
||||
"请填写昵称。": "Please enter a nickname.",
|
||||
"昵称太长了,请缩短后再发。": "The nickname is too long. Please shorten it.",
|
||||
"联系方式太长了,请缩短后再发。": "The contact is too long. Please shorten it.",
|
||||
"请重新选择反馈类别。": "Please pick a category again.",
|
||||
"截图数量超出上限,请去掉几张后再发。": "Too many screenshots. Remove a few and send again.",
|
||||
"截图只支持 PNG 或 JPEG。": "Screenshots must be PNG or JPEG.",
|
||||
"有截图太大了,请换小一点的图。": "A screenshot is too large. Use a smaller one.",
|
||||
"有截图无法读取,请换一张图。": "A screenshot could not be read. Try another image.",
|
||||
"内容不符合要求,请检查后重试。": "Something in the form was not accepted. Please check it and try again.",
|
||||
"没能连上反馈服务。已填的内容都还在,网络恢复后可以直接重试,不会重复提交。":
|
||||
"Could not reach the feedback service. Everything you entered is kept; retry once the network is back and it will not be filed twice.",
|
||||
"这次的内容没能被解析,请检查后重试。": "The request could not be read. Please check the form and try again.",
|
||||
"内容太大,服务端没有接收。请缩短文字,或去掉一张截图后再发。": "The report was too large for the service. Shorten the text or remove a screenshot, then send again.",
|
||||
"提交得太频繁了,请等 {secs} 秒后再试。": "You are sending too fast. Try again in {secs} seconds.",
|
||||
"提交得太频繁了,请稍等一会儿再试。": "You are sending too fast. Please wait a moment and try again.",
|
||||
"相同内容刚刚已经提交过,不需要再发一次。可以在「我的反馈」里查看它。": "The same text was just sent, so there is no need to send it again. You can find it under My feedback.",
|
||||
"反馈通道今天的接收量已满,不是你发得太多。内容都还在,请明天再试。": "The feedback channel has reached its capacity for today; you did not send too much. Your text is kept, so please try again tomorrow.",
|
||||
"有一张截图没能清除其中的隐藏信息。请换一张,或重新导出后再添加。": "A screenshot could not be cleaned of its hidden metadata. Pick another one, or re-export it and add it again.",
|
||||
"反馈通道暂时关闭。你可以直接到 GitHub 提交问题。": "The feedback channel is switched off for now. You can file an issue on GitHub directly.",
|
||||
"反馈服务没有认出这台电脑,请稍后重试。": "The feedback service did not recognise this computer. Please try again later.",
|
||||
"反馈服务暂时出了问题。已填的内容都还在,请稍等片刻再试,也可以直接到 GitHub 提交问题。": "The feedback service is having trouble. What you typed is kept. Try again in a moment, or file an issue on GitHub directly.",
|
||||
"反馈服务暂时出了问题。你写的回复还在,请稍等片刻;重发前先刷新列表确认是否送达,也可以直接到 GitHub 提交问题。": "The feedback service is having trouble. Your reply is kept. Wait a moment, then refresh the list to check whether it arrived before resending, or file an issue on GitHub directly.",
|
||||
"本机保存反馈记录时出错,请重试。": "Saving the feedback record on this machine failed. Please try again.",
|
||||
"反馈没有发出去,请稍后重试。": "The feedback was not sent. Please try again later.",
|
||||
"需要补充信息": "Needs more info",
|
||||
"维护者已回复": "Answered by us",
|
||||
"已关闭此反馈": "Closed",
|
||||
"需要你回复": "Needs your reply",
|
||||
"等你补充信息": "Waiting for your information",
|
||||
"维护者": "Maintainers",
|
||||
"新": "New",
|
||||
"显示更早的 {n} 条": "Show {n} earlier messages",
|
||||
"回复": "Reply",
|
||||
"回复维护者": "Reply to the maintainers",
|
||||
"你的回复": "Your reply",
|
||||
"发送回复": "Send reply",
|
||||
"离线时暂时不能回复。": "You cannot reply while offline.",
|
||||
"这份反馈已有公开议题 #{n}:你的回复会被公开转发到那个议题里。请不要写入密钥、密码或私有内容。": "This report already has a public issue, #{n}: your reply is copied there publicly. Do not include secrets, passwords or private content.",
|
||||
"只发给维护者,不会公开。请不要写入密钥、密码或私有代码。": "Sent to the maintainers only; it is not made public. Do not include secrets, passwords or private code.",
|
||||
"找不到这条反馈,请刷新列表后再试。": "That report could not be found. Refresh the list and try again.",
|
||||
"回复不能为空。": "The reply is empty.",
|
||||
"回复太长了,请缩短后再发。": "The reply is too long. Please shorten it and send again.",
|
||||
"回复不符合要求,请检查后重试。": "The reply was not accepted. Please check it and try again.",
|
||||
"这份反馈的回复次数已到上限,或你回复得太频繁了。请稍后再试,必要时另外提交一条新反馈。": "This report has reached its reply limit, or you replied too often. Try again later, or send a new report.",
|
||||
"这份反馈现在不接收回复,它可能已经处理完毕或被关闭。请刷新列表查看最新状态。": "This report takes no reply right now; it may be finished or closed. Refresh the list to see its latest status.",
|
||||
"这台电脑的反馈身份已经变了,这份反馈不能再从这里回复。可以另外提交一条新反馈。": "This computer's feedback identity has changed, so this report can no longer be answered from here. You can send a new report.",
|
||||
"反馈通道暂时关闭,暂时不能回复。": "The feedback channel is switched off for now, so replies are not possible.",
|
||||
"没能连上反馈服务。你写的回复还在;如果不确定它有没有送达,请先刷新列表看看,再决定要不要重发。": "Could not reach the feedback service. Your reply is kept; if you are not sure it arrived, refresh the list first, then decide whether to send it again.",
|
||||
"回复没有发出去,请稍后重试。": "The reply was not sent. Please try again later.",
|
||||
"服务暂时要求额外验证,当前版本还不能显示验证步骤。请稍后再试,或直接到 GitHub 提交问题。": "The service temporarily asks for an extra verification step that this version cannot show. Try again later, or file an issue on GitHub directly.",
|
||||
"{n} 项待查看": "{n} to review",
|
||||
"有 {n} 份反馈收到了新回复。": "{n} of your reports have new replies.",
|
||||
"回复已发送。": "Reply sent.",
|
||||
};
|
||||
@@ -3,6 +3,20 @@
|
||||
// code adds one here. i18n.test.ts is what stops one from being forgotten.
|
||||
|
||||
export const EN_KERNEL: Record<string, string> = {
|
||||
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
|
||||
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
|
||||
"这份反馈现在不接收回复": "This report takes no reply right now",
|
||||
"反馈服务要求额外验证,请稍后再试": "The feedback service asks for extra verification - try again later",
|
||||
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
|
||||
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
|
||||
"反馈内容过大,请缩短文字或减少截图": "That feedback is too large - shorten the text or attach fewer screenshots",
|
||||
"提交太频繁了,请稍后再试": "Too many submissions - try again a little later",
|
||||
"反馈通道暂时关闭,请稍后再试,或直接到 GitHub 提交问题": "Feedback is switched off for now - try again later, or open an issue on GitHub",
|
||||
"相同内容的反馈刚刚提交过了": "The same feedback was just sent",
|
||||
"反馈服务没有接受本机的反馈身份,请重试": "The feedback service did not accept this install's identity - try again",
|
||||
"无法连接反馈服务,请检查网络后重试,已填内容会保留": "The feedback service cannot be reached - check the network and retry; what you wrote is kept",
|
||||
"反馈服务暂时无法处理请求,请稍后再试": "The feedback service could not handle that - try again later",
|
||||
"本机保存反馈记录失败,请重试": "Feedback could not be recorded on this machine - try again",
|
||||
"该决定已不符合当前状态:计划在你回答前已发生变更": "That decision is no longer current - the plan changed before you answered",
|
||||
"该条已发送给模型,无法撤回": "That line already reached the model - it cannot be taken back",
|
||||
// 能力开关:名字、这台机器的存档、以及服务器自己
|
||||
@@ -119,7 +133,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"任务正在运行,请先停止再重载扩展": "A task is running — stop it before reloading extensions",
|
||||
"该来源正在使用中,请先切换模型再删除": "This source is in use — switch models before deleting it",
|
||||
"请为该来源填写名称": "Give this source a name",
|
||||
"名称只能包含字母、数字、点、连字符和下划线": "A name may use letters, digits, dot, dash and underscore only",
|
||||
"名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符": "Use only letters, digits, dots, hyphens and underscores, starting with a letter or digit, up to 64 characters",
|
||||
"已经有名为「{name}」的连接了,换一个名称": "A connection named “{name}” already exists; pick another name",
|
||||
"读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试": "The configuration could not be read, so there is no safe place to store the key; check the config and try again",
|
||||
"请填写接口地址": "Enter an endpoint address",
|
||||
@@ -149,6 +163,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"无法在 {ip} 上开启监听:{error}": "Could not listen on {ip}: {error}",
|
||||
"没有这台已配对的设备,可能已被移除。": "No paired device has that id; it may already have been removed.",
|
||||
"这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.",
|
||||
"这个文件夹不在当前窗口的项目列表里。": "That folder is not a project in this window.",
|
||||
"项目文件夹已不在磁盘上。": "The project folder is no longer on disk.",
|
||||
"这个内核不在本机,没法在系统文件管理器中显示它的文件。": "This kernel is not on this machine, so its files cannot be shown in the system file manager.",
|
||||
"这个系统没有可用的文件夹选择框,请直接填写路径。": "This system has no folder picker available; type the path instead.",
|
||||
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
|
||||
@@ -158,6 +174,16 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该 key 长度异常,可能粘贴了错误内容": "That key is too long — something else was probably pasted",
|
||||
"已连接,无需重复配置": "Already connected — no need to set it up again",
|
||||
"远端配置未完成,请稍后重试": "The remote setup did not complete; try again shortly",
|
||||
"没有这个模型连接": "There is no such model connection",
|
||||
"该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env": "This connection has no variable to store a key in — set its api_key_env in the configuration first",
|
||||
"该 key 超长或含换行,请重新复制": "That key is too long or spans several lines — copy it again",
|
||||
"已保存的密钥在此期间被改动,请重新打开配置再试": "The stored keys changed in the meantime — reopen the setup and try again",
|
||||
"密钥已保存,但连接尚未生效,请重试": "The key was saved but the connection is not active yet — try again",
|
||||
"服务商拒绝了该 key,请检查是否复制完整": "The provider rejected that key — check it was copied in full",
|
||||
"服务商没有及时应答,请稍后重试": "The provider did not answer in time — try again shortly",
|
||||
"连不上服务商,请检查网络或服务地址": "Cannot reach the provider — check the network or the service address",
|
||||
"服务商返回了错误,请稍后重试": "The provider returned an error — try again shortly",
|
||||
"连接测试未通过": "The connection test did not pass",
|
||||
"这台服务器已关闭会话切换": "This server has session switching turned off",
|
||||
"该会话正在清理,请稍后再打开": "This session is being cleaned up; open it again in a moment",
|
||||
"该会话正被其他位置占用,请先关闭该处": "This session is held elsewhere — close it there first",
|
||||
@@ -228,6 +254,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这份备份已不存在": "That backup no longer exists",
|
||||
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
|
||||
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
|
||||
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
|
||||
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
|
||||
"不存在该预设": "There is no such preset",
|
||||
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
|
||||
|
||||
@@ -131,6 +131,25 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"请先更新这台电脑上的 Studio,再连接。": "Update Studio on this computer before connecting.",
|
||||
"无法授权 Web Studio,请重新登录后再试。": "Web Studio could not be authorized. Sign in again and retry.",
|
||||
"连接远程 Studio 超时,请检查电脑是否在线。": "Connecting to Remote Studio timed out. Check that the computer is online.",
|
||||
"网络已断开": "You're offline",
|
||||
"当前设备没有网络。请检查网络连接后重新连接。": "This device has no network. Check your connection, then reconnect.",
|
||||
"无法连接远程服务": "Can't reach the remote service",
|
||||
"当前网络访问不到 Reasonix 远程服务,可能是网络受限或服务暂时中断。请切换网络后重新连接。":
|
||||
"This network can't reach the Reasonix remote service. It may be restricted, or the service may be briefly down. Switch networks and reconnect.",
|
||||
"此网址不能使用远程中转": "This address can't use the relay",
|
||||
"打开 reasonix.io": "Open reasonix.io",
|
||||
"远程中转拒绝了当前网页的来源。请从 reasonix.io 打开 Web Studio 后再连接。":
|
||||
"The relay rejected this page's origin. Open Web Studio from reasonix.io, then connect.",
|
||||
"尝试次数过多": "Too many attempts",
|
||||
"请求过于频繁。请等待 {n} 秒后重新连接。": "Too many requests. Wait {n} seconds, then reconnect.",
|
||||
"请求过于频繁。请稍等片刻后重新连接。": "Too many requests. Wait a moment, then reconnect.",
|
||||
"中转拒绝了这次连接": "The relay refused this connection",
|
||||
"连接凭证可能已过期,或这台电脑上同时连接的设备已满。请重新连接;仍然失败请等一分钟再试。":
|
||||
"The connection ticket may have expired, or this computer already has its maximum number of connected devices. Reconnect; if it still fails, wait a minute.",
|
||||
"长时间没有操作": "Inactive for a while",
|
||||
"为节省资源,中转已回收这条空闲连接。点击重新连接即可继续。": "The relay released this idle connection. Reconnect to continue.",
|
||||
"远程连接失败": "Remote connection failed",
|
||||
"{action}({n})": "{action} ({n})",
|
||||
"远程中转服务暂时不可用,请稍后重试。": "The remote relay is temporarily unavailable. Try again shortly.",
|
||||
"远程 Studio 身份校验失败,请停止连接并检查设备。": "Remote Studio identity verification failed. Stop and check the device.",
|
||||
"需要重新登录": "Sign in again",
|
||||
|
||||
@@ -187,6 +187,20 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"工具": "Tool",
|
||||
"处理方式": "Verdict",
|
||||
"添加规则": "Add a rule",
|
||||
"工具执行前": "Before a tool runs",
|
||||
"工具执行后": "After a tool runs",
|
||||
"工具执行失败后": "After a tool fails",
|
||||
"请求权限时": "When permission is requested",
|
||||
"提交消息时": "When a message is submitted",
|
||||
"本轮结束时": "When the turn ends",
|
||||
"本轮失败时": "When the turn fails",
|
||||
"模型响应完成后": "After a model response completes",
|
||||
"会话开始时": "When a session starts",
|
||||
"会话结束时": "When a session ends",
|
||||
"子代理开始时": "When a subagent starts",
|
||||
"子代理结束时": "When a subagent ends",
|
||||
"需要你关注时": "When your attention is needed",
|
||||
"压缩上下文前": "Before context is compacted",
|
||||
"例如 git push:* (留空 = 整个工具)": "e.g. git push:* (empty = the whole tool)",
|
||||
"例如 *.env* (留空 = 整个工具)": "e.g. *.env* (empty = the whole tool)",
|
||||
"文件工具读写 .env 一律拒绝。bash 中的 cat 属于另一条路径,本规则不覆盖":
|
||||
@@ -268,6 +282,14 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"推理强度选「自动」时使用的档位。": "The level used when reasoning effort is set to Auto.",
|
||||
"{n} 个档位": "{n} levels",
|
||||
"{n} 个模型单独设置": "{n} models with their own levels",
|
||||
"按模型设置限制": "Limits per model",
|
||||
"留空的模型沿用上面的值,灰字是它当前继承到的值;填写后只对该模型生效。":
|
||||
"A blank field follows the values above; the grey text is what that model inherits now. A value applies to that model only.",
|
||||
"{model} 的上下文窗口": "Context window for {model}",
|
||||
"{model} 的最大输出": "Max output for {model}",
|
||||
"继承 {n}": "Inherits {n}",
|
||||
"继承:未声明": "Inherits: not declared",
|
||||
"继承:自动": "Inherits: automatic",
|
||||
"按模型设置档位": "Levels per model",
|
||||
"同一接入下有多家厂商的模型时,可为单个模型指定档位;「继承接入设置」沿用上面的档位。模型自己的档位优先。":
|
||||
"When one connection serves several vendors' models, give a model levels of its own. Inherit uses the levels above; a model's own levels win.",
|
||||
@@ -324,6 +346,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"Use this after editing extension code, or installing, removing, enabling or disabling a package. The current turn is unaffected; the next one uses the new configuration.",
|
||||
"正在重启常驻进程,重新扫描技能、命令和钩子…": "Restarting resident processes and rescanning skills, commands and hooks…",
|
||||
"已生效,下一轮开始用新的扩展": "Live — the next turn runs the new extensions",
|
||||
"更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。": "Changes saved, but the runtime did not reload: {reason}. Use Reload runtime to retry.",
|
||||
"插件包": "Plugin packages",
|
||||
"一个包可以同时提供技能、命令、自动化钩子和外部服务。安装与导入是同一个操作:提供一个仓库地址,或本机的一个文件夹。":
|
||||
"One package can bring skills, commands, automation hooks and external services at once. Installing and importing are the same action: give it a repository address or a folder on this machine.",
|
||||
@@ -409,6 +432,14 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"上次填写未通过:{note}": "The last answer was not accepted: {note}",
|
||||
"填写这一项": "Fill this in",
|
||||
"已提交给 {name}": "Sent to {name}",
|
||||
"MCP 服务器 {name} 请求外部操作": "MCP server {name} is asking for an external interaction",
|
||||
"请在自己的浏览器中完成操作,完成后再继续。打开页面不会自动确认,也不会把浏览器内容交给 agent。": "Complete the interaction in your own browser, then continue. Opening the page does not confirm completion or share its contents with the agent.",
|
||||
"此链接指向本机或私有网络。打开前请确认你信任该服务器和目标地址。": "This link targets this device or a private network. Open it only if you trust the server and the destination.",
|
||||
"请核对完整地址:此链接使用 HTTP 或国际化域名。": "Check the full address: this link uses HTTP or an internationalized domain.",
|
||||
"无法打开浏览器。请重试,或手动打开上面的地址。": "Could not open the browser. Retry, or open the address above manually.",
|
||||
"已确认完成": "Completion confirmed",
|
||||
"打开浏览器": "Open browser",
|
||||
"已完成,继续": "Completed, continue",
|
||||
"已拒绝提供": "Declined",
|
||||
"拒绝提供": "Decline",
|
||||
"(补写过一次)": " (repaired once)",
|
||||
@@ -451,6 +482,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
" · 缺 key": " · no key",
|
||||
"安装这个版本": "Install this version",
|
||||
"保存中…": "Saving…",
|
||||
"无法保存": "Could not save",
|
||||
"不发送": "Do not send",
|
||||
"当前版本": "Current version",
|
||||
"读取中…": "Reading…",
|
||||
@@ -595,8 +627,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"在浏览器里打开的页面输入这串代码。没自动打开就手动访问 {uri}":
|
||||
"Type this code into the page your browser opened. If none opened, go to {uri}",
|
||||
"装好了,下一轮就能用": "Installed — the next turn can use it",
|
||||
"装好了,但这一轮还在跑:等它结束或新建会话后生效":
|
||||
"Installed, but this turn is still running: it takes effect when the turn ends, or in a new session",
|
||||
"装好了,但运行时未重载:{reason}。请用「重载运行时」重试。":
|
||||
"Installed, but the runtime did not reload: {reason}. Use Reload runtime to retry.",
|
||||
"运行时未重载:{reason}。请用「重载运行时」重试。":
|
||||
"The runtime did not reload: {reason}. Use Reload runtime to retry.",
|
||||
"有项目未安装成功,原因见下方。": "Not all items were installed. See the failures below.",
|
||||
"没装上": "Not installed",
|
||||
"这个地址上已经有「{name}」了。留空 key": "This address already holds “{name}”. Leave the key blank",
|
||||
"已就位 · {n} 个工具,下一轮就能用": "In place · {n} tools, ready next turn",
|
||||
@@ -740,8 +775,30 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"这个来源包含 {n} 个技能,会全部安装": "This source holds {n} skills, and all of them will be installed",
|
||||
"我已看过这 {n} 个技能,全部安装": "I have read these {n} skills - install all of them",
|
||||
"以下项目需要逐项看过再安装": "Read each of these before installing",
|
||||
"以下项目会在本机运行程序": "These run programs on this machine",
|
||||
"以下项目会改变可用能力": "These change what is available",
|
||||
"以下项目仅添加文件": "These only add files",
|
||||
"运行于 Reasonix 内部,可读取整个会话、绕过权限并直接操作本机": "Runs inside Reasonix, can read the whole session, bypass permissions and operate this machine directly",
|
||||
"自动化钩子": "Automatic hooks",
|
||||
"在会话生命周期内自动执行": "Run automatically during the session lifecycle",
|
||||
"为 agent 提供的能力与内置工具等同": "Provides capabilities equivalent to the agent's built-in tools",
|
||||
"{n} 个 MCP 服务": "{n} MCP services",
|
||||
"1 个 MCP 服务": "1 MCP service",
|
||||
"1 条钩子": "1 hook",
|
||||
"1 个技能": "1 skill",
|
||||
"1 个命令": "1 command",
|
||||
"1 个子代理": "1 subagent",
|
||||
"1 个提示词": "1 prompt",
|
||||
"1 套配色": "1 palette",
|
||||
"将启动": "Will start",
|
||||
"将连接至": "Will connect to",
|
||||
"一个常驻进程": "a long-running process",
|
||||
"工具 {names}": "tools {names}",
|
||||
"一条钩子": "a hook",
|
||||
"一个外部服务": "an external service",
|
||||
"{n} 个外部服务": "{n} external services",
|
||||
"一个独立的 MCP 服务": "a standalone MCP service",
|
||||
"{n} 个独立的 MCP 服务": "{n} standalone MCP services",
|
||||
"无法读取 {name}": "Could not read {name}",
|
||||
"发布者": "Publisher",
|
||||
"审核版本": "Reviewed version",
|
||||
|
||||
@@ -21,8 +21,8 @@ export const EN_STORAGE: Record<string, string> = {
|
||||
"目录过大,已统计到时间上限,实际占用不小于此数": "This folder is too large to finish measuring in time; the real size is at least this much.",
|
||||
占用: "Space used",
|
||||
原位置仍有残留数据: "Data remains in the previous location",
|
||||
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此本机的壁纸、主题包或更新回滚备份可能显示为缺失。手动将这些目录复制到当前位置即可恢复。":
|
||||
"These are still in {dir}: {names}. A move left them behind, so a wallpaper, a theme pack or the backups an update rolls back to can look gone on this machine. Copying those folders into the current location restores them.",
|
||||
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此项目列表、全局指令与记忆、计划任务、壁纸、主题包或更新回滚备份可能显示为缺失。下次启动会自动并入当前位置;若仍显示,请退出后把这些文件或目录复制到当前位置,已有的文件不要覆盖。":
|
||||
"These are still in {dir}: {names}. A move left them behind, so the project list, global instructions and memory, scheduled runs, a wallpaper, a theme pack or the backups an update rolls back to can look gone on this machine. The next launch merges them into the current location; if they are still listed, quit and copy them there without overwriting files that already exist.",
|
||||
位置: "Locations",
|
||||
迁移: "Migrate",
|
||||
"迁移未能启动。": "The move could not be started.",
|
||||
|
||||
@@ -14,6 +14,12 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"运行详情": "Runtime details",
|
||||
"查看全部指标": "View all metrics",
|
||||
"子代理": "Subagents",
|
||||
"查看完整记录": "Open full transcript",
|
||||
"查看完整记录:{name}": "Open full transcript: {name}",
|
||||
"子代理完整记录:{name}": "Full transcript of subagent: {name}",
|
||||
"还没有记录到步骤": "No steps recorded yet",
|
||||
"显示全部": "Show all",
|
||||
"输出过长,仅显示前 200000 个字符": "Output too long, showing the first 200000 characters",
|
||||
"成本构成": "Cost breakdown",
|
||||
"计划待确认": "The plan is yours to decide",
|
||||
"计划已生成,如何执行由你决定。": "The plan is written. What happens next is your call.",
|
||||
|
||||
@@ -31,6 +31,14 @@ describe("what a reader is told a refusal was", () => {
|
||||
// The gate the codes were for. The kernel rewords its own English whenever a
|
||||
// sentence reads better in a log; if that moves what a reader sees, wording
|
||||
// authority never actually left the kernel.
|
||||
it("explains the project limit in Chinese and English", () => {
|
||||
const error = coded("storage limit", "workspace.limit_reached");
|
||||
expect(reason(error)).toBe("项目列表已满(32 个),请先移除一个项目再添加");
|
||||
localStorage.setItem(STORAGE, "en");
|
||||
boot();
|
||||
expect(reason(error)).toBe("The project list is full (32 projects); remove one before adding another");
|
||||
});
|
||||
|
||||
it("does not move when the kernel rewords the same code", () => {
|
||||
const before = reason(coded("inbox item not found", "inbox.not_found"));
|
||||
const after = reason(coded("no such entry in this session's inbox", "inbox.not_found"));
|
||||
|
||||
@@ -42,7 +42,7 @@ const SAID: Record<string, string> = {
|
||||
|
||||
// ── 来源:填错了什么 ─────────────────────────────────────────────
|
||||
"provider.name_required": "请为该来源填写名称",
|
||||
"provider.name_invalid": "名称只能包含字母、数字、点、连字符和下划线",
|
||||
"provider.name_invalid": "名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符",
|
||||
"provider.name_taken": "已经有名为「{name}」的连接了,换一个名称",
|
||||
"provider.config_unreadable": "读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试",
|
||||
"provider.endpoint_required": "请填写接口地址",
|
||||
@@ -104,6 +104,7 @@ const SAID: Record<string, string> = {
|
||||
"backup.not_found": "这份备份已不存在",
|
||||
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
|
||||
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
|
||||
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
|
||||
"workspace.changing_disabled": "这台服务器不支持切换工作区",
|
||||
"settings.unknown_preset": "不存在该预设",
|
||||
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
|
||||
@@ -136,6 +137,8 @@ const SAID: Record<string, string> = {
|
||||
"editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。",
|
||||
"editor.launch_failed": "编辑器没能启动:{error}",
|
||||
"editor.no_window": "这个内核没有窗口,打不开本机的编辑器。",
|
||||
"workspace.not_listed": "这个文件夹不在当前窗口的项目列表里。",
|
||||
"workspace.folder_missing": "项目文件夹已不在磁盘上。",
|
||||
"workspace.locate_no_window": "这个内核不在本机,没法在系统文件管理器中显示它的文件。",
|
||||
"device.host_only": "这项操作只能在电脑上的窗口里做,已配对的手机做不了。",
|
||||
"device.host_rejected": "这个地址不是本机共享的地址,请重新扫码。",
|
||||
@@ -157,6 +160,16 @@ const SAID: Record<string, string> = {
|
||||
"provider.key_too_large": "该 key 长度异常,可能粘贴了错误内容",
|
||||
"provider.setup_done": "已连接,无需重复配置",
|
||||
"provider.setup_failed": "远端配置未完成,请稍后重试",
|
||||
"provider.unknown": "没有这个模型连接",
|
||||
"provider.no_key_slot": "该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env",
|
||||
"provider.key_invalid": "该 key 超长或含换行,请重新复制",
|
||||
"provider.credentials_changed": "已保存的密钥在此期间被改动,请重新打开配置再试",
|
||||
"provider.activation_failed": "密钥已保存,但连接尚未生效,请重试",
|
||||
"provider.test_auth": "服务商拒绝了该 key,请检查是否复制完整",
|
||||
"provider.test_timeout": "服务商没有及时应答,请稍后重试",
|
||||
"provider.test_unreachable": "连不上服务商,请检查网络或服务地址",
|
||||
"provider.test_upstream": "服务商返回了错误,请稍后重试",
|
||||
"provider.test_failed": "连接测试未通过",
|
||||
|
||||
"memory.unavailable": "该会话未启用记忆",
|
||||
|
||||
@@ -327,6 +340,20 @@ const SAID: Record<string, string> = {
|
||||
"wallet.unauthorized": "该供应商拒绝了当前密钥,无法读取余额",
|
||||
"wallet.unreachable": "该供应商的余额接口无响应",
|
||||
"wallet.unreadable": "无法解析该供应商余额接口返回的内容",
|
||||
"feedback.invalid": "反馈内容不符合要求,请检查后重试",
|
||||
"feedback.too_large": "反馈内容过大,请缩短文字或减少截图",
|
||||
"feedback.rate_limited": "提交太频繁了,请稍后再试",
|
||||
"feedback.disabled": "反馈通道暂时关闭,请稍后再试,或直接到 GitHub 提交问题",
|
||||
"feedback.duplicate": "相同内容的反馈刚刚提交过了",
|
||||
"feedback.bad_token": "反馈服务没有接受本机的反馈身份,请重试",
|
||||
"feedback.offline": "无法连接反馈服务,请检查网络后重试,已填内容会保留",
|
||||
"feedback.unavailable": "反馈服务暂时无法处理请求,请稍后再试",
|
||||
"feedback.internal": "本机保存反馈记录失败,请重试",
|
||||
"feedback.busy": "反馈通道今日已满,请明天再试",
|
||||
"feedback.image_metadata": "截图无法清除其中的元数据,请换一张或先用截图工具重新导出",
|
||||
"feedback.reply_limit": "这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试",
|
||||
"feedback.not_replyable": "这份反馈现在不接收回复",
|
||||
"feedback.challenge_required": "反馈服务要求额外验证,请稍后再试",
|
||||
|
||||
// ── 远程连接停下来问的那一句 ───────────────────────────────────
|
||||
"ask.not_found": "不存在该待回答的问题",
|
||||
|
||||
@@ -16,6 +16,9 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
|
||||
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
|
||||
await_user: "等待你的输入",
|
||||
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
|
||||
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
|
||||
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
|
||||
context_budget: "上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。",
|
||||
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// Why a held queue entry stopped, keyed by the kernel's block code. A code with
|
||||
// no entry shows the kernel's own diagnostic.
|
||||
export const BLOCK_WHY: Record<string, string> = {
|
||||
steer_unapplied: "这条插话在本轮结束前没来得及送达,没有发给模型",
|
||||
turn_snapshot_failed: "本轮已结束,但对话记录没能保存",
|
||||
turn_ack_failed: "本轮已结束,但队列没能确认这一条",
|
||||
owner_inactive: "运行这一条的会话已不在了",
|
||||
manifest_salvaged: "队列记录损坏,这一条是从残留文件里找回的",
|
||||
};
|
||||
@@ -6,6 +6,8 @@ import { track as trackWidth } from "./ui/viewport";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/feedback.css";
|
||||
import "./styles/community.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
@@ -14,7 +16,13 @@ import { install as installFileDrop } from "./ui/filedrop";
|
||||
import { host } from "./port/host";
|
||||
import { play } from "./boot/intro";
|
||||
import { settled } from "./boot/gate";
|
||||
import { onRemoteConnectionEnded, remoteConnectionEnded, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
|
||||
import { failureView, type FailureView } from "./port/cloud_failure";
|
||||
import { RemoteLinkError } from "./port/cloud_link";
|
||||
import { RemoteNotice } from "./ui/RemoteNotice";
|
||||
import { onRemoteConnectionEnded, remoteConnectionEnded, openRemoteHome, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
|
||||
|
||||
const failureAction = (view: FailureView) => (view.action === "home" ? openRemoteHome : () => location.reload());
|
||||
const failureLabel = (view: FailureView) => (view.action === "home" ? t("打开 reasonix.io") : t("重新连接"));
|
||||
|
||||
function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }) {
|
||||
const [ended, setEnded] = useState<RemoteEnd | null>(() => (remote ? remoteConnectionEnded() : null));
|
||||
@@ -23,22 +31,21 @@ function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }
|
||||
return onRemoteConnectionEnded(setEnded);
|
||||
}, [remote]);
|
||||
const reauth = ended?.kind === "reauth";
|
||||
const specific = !reauth && ended?.failure ? failureView(ended.failure) : null;
|
||||
return (
|
||||
<>
|
||||
<App hub={hub} />
|
||||
{ended && remote && (
|
||||
<div className="remote-closed" role="alert" aria-label={reauth ? t("需要重新登录") : t("远程连接已断开")}>
|
||||
<span aria-hidden="true" />
|
||||
<div>
|
||||
<b>{reauth ? t("需要重新登录") : t("远程连接已断开")}</b>
|
||||
<p>{reauth
|
||||
? t("为了安全,远程控制在登录满 24 小时或退出登录后需要重新登录。")
|
||||
: t("这台电脑已停止网页控制。重新连接需要再次验证设备状态。")}</p>
|
||||
</div>
|
||||
<button onClick={() => (reauth ? void signInAgain(remote) : location.reload())}>
|
||||
{reauth ? t("重新登录") : t("重新连接")}
|
||||
</button>
|
||||
</div>
|
||||
<RemoteNotice
|
||||
title={reauth ? t("需要重新登录") : specific?.title ?? t("远程连接已断开")}
|
||||
body={reauth
|
||||
? t("为了安全,远程控制在登录满 24 小时或退出登录后需要重新登录。")
|
||||
: specific?.body ?? t("这台电脑已停止网页控制。重新连接需要再次验证设备状态。")}
|
||||
action={reauth ? t("重新登录") : specific ? failureLabel(specific) : t("重新连接")}
|
||||
waitS={specific?.waitS}
|
||||
retryWhenOnline={specific?.retryWhenOnline}
|
||||
onAction={reauth ? () => void signInAgain(remote) : specific ? failureAction(specific) : () => location.reload()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -153,13 +160,21 @@ pick().then(
|
||||
arrive(settled);
|
||||
},
|
||||
(e: unknown) => {
|
||||
root.render(
|
||||
const failure = e instanceof RemoteLinkError && e.failure ? failureView(e.failure) : null;
|
||||
root.render(failure ? (
|
||||
<div className="app" data-run="idle">
|
||||
<RemoteNotice
|
||||
title={failure.title} body={failure.body} action={failureLabel(failure)} waitS={failure.waitS}
|
||||
retryWhenOnline={failure.retryWhenOnline} onAction={failureAction(failure)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app" data-run="idle">
|
||||
<div className="errbar" role="alert">
|
||||
<span>{reason(e)}</span>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
</div>
|
||||
));
|
||||
// The failure is the one thing that must not stay behind the boot screen.
|
||||
arrive(Promise.resolve());
|
||||
},
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { classifyHandshakeFailure, failureView, retryAfterSeconds } from "./cloud_failure";
|
||||
import { RemoteLinkError } from "./cloud_link";
|
||||
import { remoteHub } from "./cloud_remote";
|
||||
|
||||
const grantBody = {
|
||||
grant: { ticket: "t".repeat(64) },
|
||||
device: { id: "dev-1", publicKey: "", capabilities: ["desktop"], revokedAt: null },
|
||||
};
|
||||
|
||||
class RefusedSocket extends EventTarget {
|
||||
static last: RefusedSocket | null = null;
|
||||
readyState = 0;
|
||||
constructor() {
|
||||
super();
|
||||
RefusedSocket.last = this;
|
||||
queueMicrotask(() => this.dispatchEvent(new Event("error")));
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
|
||||
function stubNetwork(routes: {
|
||||
grant?: () => Response | Promise<Response>;
|
||||
health?: () => Response | Promise<Response>;
|
||||
probe?: () => Response | Promise<Response>;
|
||||
online?: boolean;
|
||||
}) {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
calls.push(`${init?.method ?? "GET"} ${new URL(url).pathname}`);
|
||||
if (url.endsWith("/me/remote-grants")) return routes.grant?.() ?? Response.json(grantBody);
|
||||
if (url.endsWith("/health")) return routes.health?.() ?? new Response(null, { status: 200 });
|
||||
if (url.endsWith("/v1/probe")) return routes.probe?.() ?? Response.json({ ok: true });
|
||||
throw new Error(`unexpected ${url}`);
|
||||
}));
|
||||
vi.stubGlobal("WebSocket", RefusedSocket);
|
||||
Object.defineProperty(navigator, "onLine", { configurable: true, get: () => routes.online ?? true });
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function failureOf(): Promise<RemoteLinkError> {
|
||||
const error = await remoteHub("dev-1").then(() => null, (e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(RemoteLinkError);
|
||||
return error as RemoteLinkError;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("handshake failure classification", () => {
|
||||
it("says offline without touching the network", async () => {
|
||||
const calls = stubNetwork({ online: false });
|
||||
const found = await classifyHandshakeFailure({ relay: "wss://remote.example", online: () => false, fetch: fetch as typeof globalThis.fetch });
|
||||
expect(found).toEqual({ code: "offline" });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("tells the gateway's own answers apart, and only then names an origin", async () => {
|
||||
const relay = "wss://remote.example";
|
||||
const ask = () => classifyHandshakeFailure({ relay, online: () => true, fetch: fetch as typeof globalThis.fetch });
|
||||
const down = () => { throw new TypeError("Failed to fetch"); };
|
||||
const refusal = () => new Response(JSON.stringify({ error: { code: "origin_rejected" } }), { status: 403 });
|
||||
|
||||
stubNetwork({ probe: down, health: down });
|
||||
expect(await ask()).toEqual({ code: "unreachable" });
|
||||
|
||||
stubNetwork({ probe: refusal });
|
||||
expect(await ask()).toEqual({ code: "origin_rejected" });
|
||||
|
||||
const calls = stubNetwork({});
|
||||
expect(await ask()).toEqual({ code: "refused" });
|
||||
expect(calls).toEqual(["GET /v1/probe"]);
|
||||
});
|
||||
|
||||
it("does not blame the origin for outages, throttling or unreadable answers", async () => {
|
||||
const relay = "wss://remote.example";
|
||||
const ask = () => classifyHandshakeFailure({ relay, online: () => true, fetch: fetch as typeof globalThis.fetch });
|
||||
const down = () => { throw new TypeError("Failed to fetch"); };
|
||||
|
||||
stubNetwork({ probe: down });
|
||||
expect(await ask()).toEqual({ code: "unknown" });
|
||||
stubNetwork({ probe: () => new Response("<html>1101</html>", { status: 500 }) });
|
||||
expect(await ask()).toEqual({ code: "unreachable" });
|
||||
stubNetwork({ probe: () => new Response(null, { status: 502 }) });
|
||||
expect(await ask()).toEqual({ code: "unreachable" });
|
||||
stubNetwork({ probe: () => new Response("{}", { status: 403 }) });
|
||||
expect(await ask()).toEqual({ code: "unknown" });
|
||||
stubNetwork({ probe: () => new Response(null, { status: 429, headers: { "retry-after": "20" } }) });
|
||||
expect(await ask()).toEqual({ code: "rate_limited", retryAfterS: 20 });
|
||||
});
|
||||
|
||||
it("asks again whether the phone is online once a probe has failed", async () => {
|
||||
const down = () => { throw new TypeError("Failed to fetch"); };
|
||||
stubNetwork({ probe: down, health: down });
|
||||
const answers = [true, false];
|
||||
const found = await classifyHandshakeFailure({
|
||||
relay: "wss://remote.example", online: () => answers.shift() ?? false, fetch: fetch as typeof globalThis.fetch,
|
||||
});
|
||||
expect(found).toEqual({ code: "offline" });
|
||||
});
|
||||
|
||||
it("reads Retry-After only as a positive number of seconds", () => {
|
||||
expect(retryAfterSeconds("30")).toBe(30);
|
||||
expect(retryAfterSeconds("0")).toBeUndefined();
|
||||
expect(retryAfterSeconds("Wed, 21 Oct 2026 07:28:00 GMT")).toBeUndefined();
|
||||
expect(retryAfterSeconds(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives every code its own wording and reconnect action, with the wait only for rate limits", () => {
|
||||
expect(failureView({ code: "origin_rejected" }).action).toBe("home");
|
||||
expect(failureView({ code: "offline" }).retryWhenOnline).toBe(true);
|
||||
expect(failureView({ code: "refused" }).retryWhenOnline).toBe(false);
|
||||
const codes = ["offline", "unreachable", "origin_rejected", "rate_limited", "refused", "idle", "unknown"] as const;
|
||||
const titles = codes.map((code) => failureView({ code }).title);
|
||||
expect(new Set(titles).size).toBe(codes.length);
|
||||
expect(failureView({ code: "rate_limited", retryAfterS: 42 })).toMatchObject({ waitS: 42, action: "reconnect" });
|
||||
expect(failureView({ code: "rate_limited", retryAfterS: 9999 }).waitS).toBe(600);
|
||||
expect(failureView({ code: "refused", retryAfterS: 42 }).waitS).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote connect failures", () => {
|
||||
it("names an offline phone", async () => {
|
||||
stubNetwork({ online: false, health: () => { throw new TypeError("x"); } });
|
||||
const error = await failureOf();
|
||||
expect(error.failure).toEqual({ code: "offline" });
|
||||
expect(error.kind).toBe("transient");
|
||||
});
|
||||
|
||||
it("names a gateway the phone cannot reach after the socket errors", async () => {
|
||||
stubNetwork({ probe: () => { throw new TypeError("x"); }, health: () => { throw new TypeError("x"); } });
|
||||
expect((await failureOf()).failure).toEqual({ code: "unreachable" });
|
||||
});
|
||||
|
||||
it("names an origin the gateway rejects", async () => {
|
||||
stubNetwork({ probe: () => new Response(JSON.stringify({ error: { code: "origin_rejected" } }), { status: 403 }) });
|
||||
expect((await failureOf()).failure).toEqual({ code: "origin_rejected" });
|
||||
});
|
||||
|
||||
it("falls back to a refused ticket when the gateway is reachable and the origin allowed", async () => {
|
||||
stubNetwork({});
|
||||
expect((await failureOf()).failure).toEqual({ code: "refused" });
|
||||
});
|
||||
|
||||
it("reports an account-service rate limit with its Retry-After", async () => {
|
||||
stubNetwork({ grant: () => new Response(JSON.stringify({ error: { code: "rate_limited" } }), { status: 429, headers: { "retry-after": "45" } }) });
|
||||
const error = await failureOf();
|
||||
expect(error.failure).toEqual({ code: "rate_limited", retryAfterS: 45 });
|
||||
});
|
||||
|
||||
it("reports the account service being unreachable when the grant request throws", async () => {
|
||||
stubNetwork({ grant: () => { throw new TypeError("Failed to fetch"); } });
|
||||
expect((await failureOf()).failure).toEqual({ code: "unreachable" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { t } from "../i18n";
|
||||
|
||||
// Why a relay connection could not be made or kept. A browser is told nothing
|
||||
// about a refused WebSocket upgrade, so these come from what can be observed
|
||||
// around it, never from the text of an error.
|
||||
export type RelayFailure =
|
||||
| "offline"
|
||||
| "unreachable"
|
||||
| "origin_rejected"
|
||||
| "rate_limited"
|
||||
| "refused"
|
||||
| "idle"
|
||||
| "unknown";
|
||||
|
||||
export interface FailureDetail {
|
||||
code: RelayFailure;
|
||||
retryAfterS?: number;
|
||||
}
|
||||
|
||||
export type FailureAction = "reconnect" | "signin" | "home";
|
||||
|
||||
export interface FailureView {
|
||||
title: string;
|
||||
body: string;
|
||||
action: FailureAction;
|
||||
waitS: number;
|
||||
retryWhenOnline: boolean;
|
||||
}
|
||||
|
||||
export function failureView({ code, retryAfterS }: FailureDetail): FailureView {
|
||||
const reconnect = { action: "reconnect" as const, waitS: 0, retryWhenOnline: false };
|
||||
switch (code) {
|
||||
case "offline":
|
||||
return { ...reconnect, retryWhenOnline: true, title: t("网络已断开"), body: t("当前设备没有网络。请检查网络连接后重新连接。") };
|
||||
case "unreachable":
|
||||
return {
|
||||
...reconnect,
|
||||
title: t("无法连接远程服务"),
|
||||
body: t("当前网络访问不到 Reasonix 远程服务,可能是网络受限或服务暂时中断。请切换网络后重新连接。"),
|
||||
};
|
||||
case "origin_rejected":
|
||||
return {
|
||||
...reconnect,
|
||||
action: "home",
|
||||
title: t("此网址不能使用远程中转"),
|
||||
body: t("远程中转拒绝了当前网页的来源。请从 reasonix.io 打开 Web Studio 后再连接。"),
|
||||
};
|
||||
case "rate_limited": {
|
||||
const waitS = retryAfterS && retryAfterS > 0 ? Math.min(Math.ceil(retryAfterS), 600) : 0;
|
||||
return {
|
||||
action: "reconnect",
|
||||
retryWhenOnline: false,
|
||||
waitS,
|
||||
title: t("尝试次数过多"),
|
||||
body: waitS > 0 ? t("请求过于频繁。请等待 {n} 秒后重新连接。", { n: waitS }) : t("请求过于频繁。请稍等片刻后重新连接。"),
|
||||
};
|
||||
}
|
||||
case "refused":
|
||||
return {
|
||||
...reconnect,
|
||||
title: t("中转拒绝了这次连接"),
|
||||
body: t("连接凭证可能已过期,或这台电脑上同时连接的设备已满。请重新连接;仍然失败请等一分钟再试。"),
|
||||
};
|
||||
case "idle":
|
||||
return {
|
||||
...reconnect,
|
||||
title: t("长时间没有操作"),
|
||||
body: t("为节省资源,中转已回收这条空闲连接。点击重新连接即可继续。"),
|
||||
};
|
||||
case "unknown":
|
||||
return { ...reconnect, title: t("远程连接失败"), body: t("远程中转服务暂时不可用,请稍后重试。") };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProbeOptions {
|
||||
relay: string;
|
||||
online: () => boolean;
|
||||
fetch: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
interface Refusal {
|
||||
error?: { code?: string };
|
||||
}
|
||||
|
||||
// The gateway's /v1/probe answers a CORS reader whether or not its origin is
|
||||
// allowed, so a status the browser can read is the gateway speaking. When
|
||||
// nothing readable comes back, the health route (which carries no CORS
|
||||
// headers) can only show that something answered, and that is "unknown", not
|
||||
// a diagnosis.
|
||||
export async function classifyHandshakeFailure(options: ProbeOptions): Promise<FailureDetail> {
|
||||
if (!options.online()) return { code: "offline" };
|
||||
const timeout = options.timeoutMs ?? PROBE_TIMEOUT_MS;
|
||||
const base = options.relay.replace(/^ws/, "http");
|
||||
let answer: Response;
|
||||
try {
|
||||
answer = await options.fetch(`${base}/v1/probe`, { mode: "cors", cache: "no-store", signal: AbortSignal.timeout(timeout) });
|
||||
} catch {
|
||||
if (!options.online()) return { code: "offline" };
|
||||
try {
|
||||
await options.fetch(`${base}/health`, { mode: "no-cors", cache: "no-store", signal: AbortSignal.timeout(timeout) });
|
||||
} catch {
|
||||
return { code: options.online() ? "unreachable" : "offline" };
|
||||
}
|
||||
return { code: "unknown" };
|
||||
}
|
||||
if (answer.ok) return { code: "refused" };
|
||||
if (answer.status === 403) {
|
||||
const body = await answer.json().catch(() => null) as Refusal | null;
|
||||
if (body?.error?.code === "origin_rejected") return { code: "origin_rejected" };
|
||||
return { code: "unknown" };
|
||||
}
|
||||
if (answer.status === 429) return { code: "rate_limited", retryAfterS: retryAfterSeconds(answer.headers.get("retry-after")) };
|
||||
return { code: answer.status >= 500 ? "unreachable" : "unknown" };
|
||||
}
|
||||
|
||||
export function retryAfterSeconds(value: string | null): number | undefined {
|
||||
if (value === null) return undefined;
|
||||
const seconds = Number(value.trim());
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined;
|
||||
}
|
||||
@@ -254,4 +254,46 @@ describe("remote link", () => {
|
||||
expect(dial).toHaveBeenCalledTimes(2);
|
||||
expect(ends[0]?.kind).toBe("ended");
|
||||
});
|
||||
|
||||
it("ends with the last attempt's failure code, and with idle when the relay reclaimed the lease", async () => {
|
||||
const limited = connection();
|
||||
const dialLimited = vi.fn(async () => {
|
||||
throw new RemoteLinkError("transient", "wait", { code: "rate_limited", retryAfterS: 30 });
|
||||
});
|
||||
new RemoteLink(limited.conn, dialLimited, (end) => ends.push(end), { retryDelaysMs: [10] });
|
||||
limited.socket.close(1006, "");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(ends[0]).toMatchObject({ kind: "ended", failure: { code: "rate_limited", retryAfterS: 30 } });
|
||||
|
||||
const idle = connection();
|
||||
const dialIdle = vi.fn(async () => { throw new RemoteLinkError("transient", "down"); });
|
||||
new RemoteLink(idle.conn, dialIdle, (end) => ends.push(end), { retryDelaysMs: [10] });
|
||||
idle.socket.close(CLOSE_IDLE, "Idle timeout");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(ends[1]).toMatchObject({ kind: "ended", failure: { code: "idle" } });
|
||||
|
||||
const plain = connection();
|
||||
new RemoteLink(plain.conn, dialIdle, (end) => ends.push(end), { retryDelaysMs: [10] });
|
||||
plain.socket.close(1006, "");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(ends[2]?.failure).toBeUndefined();
|
||||
});
|
||||
|
||||
it("waits out a Retry-After before dialling again and reports only the last attempt's cause", async () => {
|
||||
const first = connection();
|
||||
const dial = vi.fn()
|
||||
.mockRejectedValueOnce(new RemoteLinkError("transient", "wait", { code: "rate_limited", retryAfterS: 5 }))
|
||||
.mockRejectedValueOnce(new RemoteLinkError("transient", "down"));
|
||||
new RemoteLink(first.conn, dial, (end) => ends.push(end), { retryDelaysMs: [10, 10] });
|
||||
first.socket.close(1006, "");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(dial).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
expect(dial).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1_100);
|
||||
expect(dial).toHaveBeenCalledTimes(2);
|
||||
expect(ends[0]?.kind).toBe("ended");
|
||||
expect(ends[0]?.failure).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from "../i18n";
|
||||
import type { FailureDetail, RelayFailure } from "./cloud_failure";
|
||||
|
||||
// Relay close codes, decided by the relay and read here by number alone.
|
||||
export const CLOSE_IDLE = 4408;
|
||||
@@ -20,12 +21,17 @@ export function closeAction(code: number): CloseAction {
|
||||
export interface RemoteEnd {
|
||||
kind: "ended" | "reauth";
|
||||
reason: string;
|
||||
failure?: FailureDetail;
|
||||
}
|
||||
|
||||
// Why a (re)connection attempt failed, as the account service or the relay
|
||||
// said it: "transient" is worth another attempt, the others are not.
|
||||
export class RemoteLinkError extends Error {
|
||||
constructor(readonly kind: "reauth" | "ended" | "transient", readonly reason: string) {
|
||||
constructor(
|
||||
readonly kind: "reauth" | "ended" | "transient",
|
||||
readonly reason: string,
|
||||
readonly failure?: FailureDetail,
|
||||
) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
@@ -62,6 +68,8 @@ export interface RemoteLinkOptions {
|
||||
retryDelaysMs: number[];
|
||||
}
|
||||
|
||||
const MAX_RETRY_AFTER_MS = 2 * 60 * 1000;
|
||||
|
||||
const DEFAULTS: RemoteLinkOptions = {
|
||||
requestTimeoutMs: 30_000,
|
||||
retryDelaysMs: [500, 1_000, 2_000, 4_000, 8_000, 15_000],
|
||||
@@ -102,6 +110,7 @@ export class RemoteLink {
|
||||
private reconnecting = false;
|
||||
private ended = false;
|
||||
private lastHeardAt = Date.now();
|
||||
private lastCloseCode = 0;
|
||||
private sending: Promise<void> = Promise.resolve();
|
||||
private readonly options: RemoteLinkOptions;
|
||||
|
||||
@@ -179,6 +188,7 @@ export class RemoteLink {
|
||||
private lost(code: number, reason: string) {
|
||||
if (this.ended) return;
|
||||
this.current = null;
|
||||
this.lastCloseCode = code;
|
||||
const action = closeAction(code);
|
||||
if (action !== "reconnect") {
|
||||
this.finish({ kind: action === "reauth" ? "reauth" : "ended", reason });
|
||||
@@ -191,9 +201,11 @@ export class RemoteLink {
|
||||
|
||||
private async reconnect() {
|
||||
this.reconnecting = true;
|
||||
let failure: FailureDetail | undefined;
|
||||
let notBefore = 0;
|
||||
try {
|
||||
for (const delay of this.options.retryDelaysMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
for (const scheduled of this.options.retryDelaysMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(scheduled, notBefore)));
|
||||
if (this.ended) return;
|
||||
try {
|
||||
const connection = await this.dial();
|
||||
@@ -211,9 +223,16 @@ export class RemoteLink {
|
||||
this.finish({ kind: error.kind, reason: error.reason });
|
||||
return;
|
||||
}
|
||||
failure = error instanceof RemoteLinkError ? error.failure : undefined;
|
||||
notBefore = Math.min((failure?.retryAfterS ?? 0) * 1000, MAX_RETRY_AFTER_MS);
|
||||
}
|
||||
}
|
||||
this.finish({ kind: "ended", reason: t("远程 Studio 暂无响应,请检查电脑是否在线后重试。") });
|
||||
failure ??= this.lastCloseCode === CLOSE_IDLE ? { code: "idle" satisfies RelayFailure } : undefined;
|
||||
this.finish({
|
||||
kind: "ended",
|
||||
reason: t("远程 Studio 暂无响应,请检查电脑是否在线后重试。"),
|
||||
...(failure ? { failure } : {}),
|
||||
});
|
||||
} finally {
|
||||
this.reconnecting = false;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ import type { HubPort } from "./hub";
|
||||
import { SseHub } from "./hub";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { classifyHandshakeFailure, failureView, retryAfterSeconds } from "./cloud_failure";
|
||||
import { linkCodec, RemoteLink, RemoteLinkError, type RemoteConnection, type RemoteEnd } from "./cloud_link";
|
||||
|
||||
const ACCOUNT = (import.meta.env.VITE_ACCOUNTS_API || "https://id.reasonix.io").replace(/\/$/, "");
|
||||
const RELAY = (import.meta.env.VITE_REMOTE_GATEWAY || "wss://remote.reasonix.io").replace(/\/$/, "");
|
||||
const REMOTE_HOME = import.meta.env.VITE_REMOTE_HOME || "https://reasonix.io/remote/";
|
||||
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
// The socket errored before it opened: the browser hides the status, so the
|
||||
// caller has to find out why.
|
||||
export class SocketRefused extends Error {}
|
||||
let connectionEnded: RemoteEnd | null = null;
|
||||
const connectionEndedListeners = new Set<(end: RemoteEnd) => void>();
|
||||
const { bytesToBase64, base64ToBytes, concatChunks } = linkCodec;
|
||||
@@ -24,6 +29,10 @@ export const onRemoteConnectionEnded = (listener: (end: RemoteEnd) => void) => {
|
||||
return () => { connectionEndedListeners.delete(listener); };
|
||||
};
|
||||
|
||||
export function openRemoteHome() {
|
||||
location.href = REMOTE_HOME;
|
||||
}
|
||||
|
||||
// Signing out first is what makes the sign-in page ask: it sends a visitor who
|
||||
// still holds a session straight on, and that session is the one too old to
|
||||
// control a computer.
|
||||
@@ -122,7 +131,7 @@ function waitForSocketOpen(socket: WebSocket, timeout = HANDSHAKE_TIMEOUT_MS): P
|
||||
const opened = () => { cleanup(); resolve(); };
|
||||
const failed = () => {
|
||||
cleanup();
|
||||
reject(new Error(t("远程中转服务暂时不可用,请稍后重试。")));
|
||||
reject(new SocketRefused());
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
@@ -207,6 +216,10 @@ async function grantFailure(issued: Response): Promise<RemoteLinkError> {
|
||||
if (issued.status === 404 || code === "device_not_found") {
|
||||
return new RemoteLinkError("ended", t("这台电脑已从账号中移除。"));
|
||||
}
|
||||
if (issued.status === 429) {
|
||||
const detail = { code: "rate_limited" as const, retryAfterS: retryAfterSeconds(issued.headers.get("retry-after")) };
|
||||
return new RemoteLinkError("transient", failureView(detail).body, detail);
|
||||
}
|
||||
return new RemoteLinkError("transient", t("无法授权 Web Studio,请重新登录后再试。"));
|
||||
}
|
||||
|
||||
@@ -222,7 +235,8 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
|
||||
body: JSON.stringify({ targetDeviceId: deviceId, scopes: ["desktop"] }),
|
||||
}));
|
||||
} catch {
|
||||
throw new RemoteLinkError("transient", t("远程中转服务暂时不可用,请稍后重试。"));
|
||||
const detail = { code: navigator.onLine === false ? "offline" as const : "unreachable" as const };
|
||||
throw new RemoteLinkError("transient", failureView(detail).body, detail);
|
||||
}
|
||||
if (!issued.ok) throw await grantFailure(issued);
|
||||
const { grant, device: target } = await issued.json() as { grant: { ticket: string }; device?: RemoteDevice };
|
||||
@@ -256,6 +270,12 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
|
||||
} catch (error) {
|
||||
try { socket.close(1000, "Handshake failed"); } catch { /* never opened */ }
|
||||
if (error instanceof RemoteLinkError) throw error;
|
||||
if (error instanceof SocketRefused) {
|
||||
const detail = await classifyHandshakeFailure({
|
||||
relay: RELAY, online: () => navigator.onLine !== false, fetch: nativeFetch,
|
||||
}).catch(() => ({ code: "unknown" as const }));
|
||||
throw new RemoteLinkError("transient", failureView(detail).body, detail);
|
||||
}
|
||||
throw new RemoteLinkError("transient", reason(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// In-app feedback: a report the maintainers triage, the receipt that follows
|
||||
// it, and the thread of replies under it. Field names are the kernel's json
|
||||
// tags; a refusal is told apart by its code, never by its sentence.
|
||||
|
||||
export type FeedbackCategory = "bug" | "idea" | "question" | "other";
|
||||
|
||||
export const FEEDBACK_CATEGORIES: readonly FeedbackCategory[] = ["bug", "idea", "question", "other"];
|
||||
|
||||
// What a person may see. A report waiting for triage reads as received; one
|
||||
// the maintainers declined reads as closed, with no reason.
|
||||
export type FeedbackStatus =
|
||||
| "received" | "needs_info" | "answered" | "recorded" | "in_progress" | "fixed" | "wontfix" | "duplicate" | "closed";
|
||||
|
||||
export const FEEDBACK_STATUSES: readonly FeedbackStatus[] = [
|
||||
"received", "needs_info", "answered", "recorded", "in_progress", "fixed", "wontfix", "duplicate", "closed",
|
||||
];
|
||||
|
||||
// Only these take a reply from the reporter; the service has the last word.
|
||||
export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "answered", "recorded", "in_progress"];
|
||||
|
||||
export interface FeedbackEnvInfo {
|
||||
version: string;
|
||||
commit: string;
|
||||
surface: string;
|
||||
os: string;
|
||||
osVersion: string;
|
||||
arch: string;
|
||||
locale: string;
|
||||
channel: string;
|
||||
providerKind: string;
|
||||
}
|
||||
|
||||
// uploadBytes bounds one raw file the page may hand over; the kernel shrinks
|
||||
// and re-encodes it, and imageBytes is what is finally sent.
|
||||
export interface FeedbackLimits {
|
||||
bodyBytes: number;
|
||||
nameChars: number;
|
||||
contactChars: number;
|
||||
images: number;
|
||||
imageBytes: number;
|
||||
uploadBytes: number;
|
||||
replyBytes: number;
|
||||
}
|
||||
|
||||
export interface FeedbackEnv {
|
||||
env: FeedbackEnvInfo;
|
||||
displayName: string;
|
||||
limits: FeedbackLimits;
|
||||
}
|
||||
|
||||
export interface FeedbackImage {
|
||||
name: string;
|
||||
dataBase64: string;
|
||||
}
|
||||
|
||||
export interface FeedbackRequest {
|
||||
idempotencyKey: string;
|
||||
category: FeedbackCategory;
|
||||
body: string;
|
||||
displayName: string;
|
||||
contact?: string;
|
||||
// The UI language tag, so the issue can be triaged in the reporter's language.
|
||||
locale?: string;
|
||||
images: FeedbackImage[];
|
||||
// Answer to a feedback.challenge_required refusal.
|
||||
turnstileToken?: string;
|
||||
}
|
||||
|
||||
export interface FeedbackReplyReceipt {
|
||||
replyId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FeedbackReceipt {
|
||||
receipt: string;
|
||||
status: FeedbackStatus;
|
||||
createdAt: string;
|
||||
redacted: boolean;
|
||||
}
|
||||
|
||||
export type FeedbackAuthor = "maintainer" | "user";
|
||||
|
||||
// Plain text only: never linked, formatted or interpreted.
|
||||
export interface FeedbackReply {
|
||||
id: number;
|
||||
author: FeedbackAuthor;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FeedbackItem {
|
||||
receipt: string;
|
||||
category: FeedbackCategory;
|
||||
titleSnippet: string;
|
||||
status: FeedbackStatus;
|
||||
issueNumber?: number;
|
||||
issueUrl?: string;
|
||||
// "vX.Y.Z", or the literal "next" while the fix is merged and not yet shipped.
|
||||
resolvedVersion?: string;
|
||||
duplicateOf?: number;
|
||||
// The report was filed under an install identity this machine no longer holds.
|
||||
statusUnavailable?: boolean;
|
||||
// The maintainers are waiting for the reporter's answer.
|
||||
needsInput: boolean;
|
||||
// Oldest first.
|
||||
replies: FeedbackReply[];
|
||||
// Maintainer replies newer than the last one the kernel was told was seen.
|
||||
unreadReplies: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// offline: the service was unreachable, so items are the receipts remembered
|
||||
// on this machine and their statuses may be stale.
|
||||
// unread counts the reports wanting attention; hasNew is unread > 0.
|
||||
export interface FeedbackMine {
|
||||
items: FeedbackItem[];
|
||||
offline: boolean;
|
||||
unread: number;
|
||||
hasNew: boolean;
|
||||
}
|
||||
|
||||
export const FEEDBACK_REPO_ISSUES = "https://github.com/esengine/DeepSeek-Reasonix/issues/";
|
||||
|
||||
export const FEEDBACK_NEXT_VERSION = "next";
|
||||
|
||||
export const FEEDBACK_CODE = {
|
||||
invalid: "feedback.invalid",
|
||||
tooLarge: "feedback.too_large",
|
||||
rateLimited: "feedback.rate_limited",
|
||||
busy: "feedback.busy",
|
||||
imageMetadata: "feedback.image_metadata",
|
||||
disabled: "feedback.disabled",
|
||||
duplicate: "feedback.duplicate",
|
||||
badToken: "feedback.bad_token",
|
||||
replyLimit: "feedback.reply_limit",
|
||||
notReplyable: "feedback.not_replyable",
|
||||
challengeRequired: "feedback.challenge_required",
|
||||
offline: "feedback.offline",
|
||||
unavailable: "feedback.unavailable",
|
||||
internal: "feedback.internal",
|
||||
badBody: "request.bad_body",
|
||||
} as const;
|
||||
@@ -19,7 +19,7 @@ export interface HostPort {
|
||||
toggleMaximiseWindow(): void;
|
||||
isWindowMaximised(): Promise<boolean>;
|
||||
closeWindow(): void;
|
||||
openExternal(url: string): void;
|
||||
openExternal(url: string): void | Promise<void>;
|
||||
/** Where dropped files live. Empty where the shell cannot say — a browser
|
||||
* tab never learns a path. */
|
||||
pathsForFiles(files: File[]): string[];
|
||||
@@ -38,6 +38,9 @@ export interface HostPort {
|
||||
* root. The shell asks the kernel where that is and selects only the answer;
|
||||
* null once shown, otherwise why it was not. */
|
||||
revealPath(base: string, path: string): Promise<Refusal | null>;
|
||||
/** Show a project the sidebar lists, which needs no pane. The hub answers
|
||||
* where it is and refuses any folder it does not list. */
|
||||
revealWorkspace(root: string): Promise<Refusal | null>;
|
||||
/** Whether this shell draws the agent's browser pages inside the window. */
|
||||
drawsBrowserViews(): boolean;
|
||||
/** Draw one of the agent's pages over rect, in on-screen coordinates, and
|
||||
@@ -127,6 +130,7 @@ interface ElectronBridge {
|
||||
saveBytes(name: string, bytes: Uint8Array): Promise<string>;
|
||||
pickFolder(startIn: string): Promise<string>;
|
||||
revealPath?(base: string, path: string): Promise<Refusal | null>;
|
||||
revealWorkspace?(root: string): Promise<Refusal | null>;
|
||||
showBrowserView?(target: string, rect: ViewRect): Promise<void>;
|
||||
hideBrowserView?(): Promise<void>;
|
||||
freezeBrowserView?(): Promise<string>;
|
||||
@@ -169,7 +173,7 @@ class ElectronHost implements HostPort {
|
||||
void this.api.closeWindow();
|
||||
}
|
||||
openExternal(url: string) {
|
||||
void this.api.openExternal(url);
|
||||
return this.api.openExternal(url);
|
||||
}
|
||||
pathsForFiles(files: File[]) {
|
||||
// Resolved one at a time because that is the shape the platform offers;
|
||||
@@ -186,11 +190,14 @@ class ElectronHost implements HostPort {
|
||||
return this.api.pickFolder(startIn);
|
||||
}
|
||||
revealsFiles() {
|
||||
return typeof this.api.revealPath === "function";
|
||||
return typeof this.api.revealPath === "function" && typeof this.api.revealWorkspace === "function";
|
||||
}
|
||||
revealPath(base: string, path: string) {
|
||||
return this.api.revealPath?.(base, path) ?? Promise.resolve({ error: "this shell cannot show files" });
|
||||
}
|
||||
revealWorkspace(root: string) {
|
||||
return this.api.revealWorkspace?.(root) ?? Promise.resolve({ error: "this shell cannot show files" });
|
||||
}
|
||||
// A shell older than the verbs has no views to draw, and says so by lacking them.
|
||||
drawsBrowserViews() {
|
||||
return typeof this.api.showBrowserView === "function";
|
||||
@@ -257,6 +264,9 @@ class BrowserHost implements HostPort {
|
||||
revealPath() {
|
||||
return Promise.resolve({ error: "a browser tab cannot show files" });
|
||||
}
|
||||
revealWorkspace() {
|
||||
return Promise.resolve({ error: "a browser tab cannot show files" });
|
||||
}
|
||||
drawsBrowserViews() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
async function hostOver(bridge: Record<string, unknown>) {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("window", { reasonixHost: { shell: "electron", platform: "darwin", titleBar: false, ...bridge } });
|
||||
return (await import("./host")).host();
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("offers reveal only when the shell can show both a pane's file and a project", async () => {
|
||||
expect((await hostOver({ revealPath: async () => null, revealWorkspace: async () => null })).revealsFiles()).toBe(true);
|
||||
expect((await hostOver({ revealPath: async () => null })).revealsFiles()).toBe(false);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SseHub } from "./hub";
|
||||
import { HttpError } from "./port";
|
||||
@@ -47,3 +48,29 @@ describe("a failed hub call", () => {
|
||||
expect(err.detailed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonAnswer(status: number, body: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
headers: new Map([["content-type", "application/json"]]),
|
||||
};
|
||||
}
|
||||
|
||||
describe("host capabilities", () => {
|
||||
it("does not ask a kernel picker the capability probe says is absent", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
vi.stubGlobal("fetch", async (path: string, init?: RequestInit) => {
|
||||
calls.push([init?.method ?? "GET", path]);
|
||||
if (path === "/host/capabilities") {
|
||||
return jsonAnswer(200, { pickFolder: false, addWorkspace: true });
|
||||
}
|
||||
return answer(501, JSON.stringify({ code: "picker.unsupported", error: "native folder picker is unavailable" }));
|
||||
});
|
||||
|
||||
expect(await new SseHub().hostCapabilities()).toEqual({ pickFolder: false, addWorkspace: true });
|
||||
expect(calls).toEqual([["GET", "/host/capabilities"]]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { HttpError, type AgentPort } from "./port";
|
||||
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe } from "./remote";
|
||||
import type { CloudShareOffer, DeviceSelf, ShareOffer, SharePort, ShareStatus } from "./share";
|
||||
import { SsePort } from "./sse";
|
||||
import { host } from "./host";
|
||||
|
||||
// How often a client waiting on a dial looks for the question it might be
|
||||
// stopped by. A person answers this one, so half a second is not the cost —
|
||||
@@ -48,6 +49,15 @@ export interface TreeSession {
|
||||
versions?: TreeSession[];
|
||||
}
|
||||
|
||||
export interface HostCapabilities {
|
||||
// The kernel can open a picker on its own machine. A browser on a headless
|
||||
// server gets false and must offer the path API instead.
|
||||
pickFolder: boolean;
|
||||
// The kernel exposes POST /tree/workspaces. This is what makes the fallback
|
||||
// meaningful rather than sending the reader back to the desktop shell.
|
||||
addWorkspace: boolean;
|
||||
}
|
||||
|
||||
export interface TreeWorkspace {
|
||||
root: string;
|
||||
name: string;
|
||||
@@ -71,6 +81,9 @@ export interface HubPort extends SharePort {
|
||||
tree(): Promise<TreeWorkspace[]>;
|
||||
addWorkspace(path: string): Promise<TreeWorkspace>;
|
||||
removeWorkspace(path: string): Promise<void>;
|
||||
/** Show a listed project's folder in the system file manager. */
|
||||
revealWorkspace(path: string): Promise<void>;
|
||||
moveWorkspace(path: string, direction: -1 | 1): Promise<void>;
|
||||
removeSession(path: string): Promise<void>;
|
||||
archiveSession(path: string, archived: boolean): Promise<void>;
|
||||
renameSession(path: string, title: string): Promise<void>;
|
||||
@@ -111,6 +124,10 @@ export interface HubPort extends SharePort {
|
||||
// kernel that would ask is the one this build cannot reach.
|
||||
onRemoteAsk(cb: (ask: RemoteAsk) => void): () => void;
|
||||
answerRemote(id: string, ok: boolean, text: string): void;
|
||||
// What the kernel can do independently of the desktop window. It is a
|
||||
// separate read so the page can choose a usable fallback before invoking a
|
||||
// host-only action.
|
||||
hostCapabilities(): Promise<HostCapabilities>;
|
||||
pickFolder(): Promise<string | null>;
|
||||
// Absolute paths of every file dropped anywhere on the window. It belongs
|
||||
// here rather than on a pane because the window has one of it: the shell
|
||||
@@ -200,10 +217,19 @@ export class SseHub implements HubPort {
|
||||
return this.post<TreeWorkspace>("/tree/workspaces", { path });
|
||||
}
|
||||
|
||||
async revealWorkspace(path: string) {
|
||||
const why = await host().revealWorkspace(path);
|
||||
if (why) throw new HttpError(0, why.error || "not shown", why, !!(why.code || why.error));
|
||||
}
|
||||
|
||||
async removeWorkspace(path: string) {
|
||||
await this.post<void>("/tree/workspaces/remove", { path });
|
||||
}
|
||||
|
||||
async moveWorkspace(path: string, direction: -1 | 1) {
|
||||
await this.post<void>("/tree/workspaces/move", { path, direction });
|
||||
}
|
||||
|
||||
async removeSession(path: string) {
|
||||
await this.post<void>("/tree/sessions/remove", { path });
|
||||
}
|
||||
@@ -346,6 +372,30 @@ export class SseHub implements HubPort {
|
||||
}
|
||||
}
|
||||
|
||||
async hostCapabilities() {
|
||||
const shellCanPick = await host()
|
||||
.describe()
|
||||
.then((info) => info.shell === "electron")
|
||||
.catch(() => false);
|
||||
|
||||
const res = await fetch("/host/capabilities", { credentials: "same-origin" });
|
||||
// A kernel predating this probe has no route and keeps the old picker
|
||||
// attempt; the caller already treats null as "no picker" safely.
|
||||
if (res.status === 404 || res.status === 501 || res.status === 503) {
|
||||
return { pickFolder: true, addWorkspace: true };
|
||||
}
|
||||
if (!res.ok) await SseHub.fail("/host/capabilities", res);
|
||||
|
||||
const body = (await res.json().catch(() => null)) as Partial<HostCapabilities> | null;
|
||||
if (typeof body?.pickFolder !== "boolean" || typeof body.addWorkspace !== "boolean") {
|
||||
return { pickFolder: true, addWorkspace: true };
|
||||
}
|
||||
return {
|
||||
pickFolder: shellCanPick || body.pickFolder,
|
||||
addWorkspace: body.addWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
async pickFolder() {
|
||||
const shellPath = await this.shell.pickFolder();
|
||||
if (shellPath !== null) return shellPath;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PlanAction } from "./session";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockBackup } from "./mock_backup";
|
||||
import { MockFeedback } from "./mock_feedback";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
|
||||
import { mockStorage, mockStoragePlan } from "./mock_storage";
|
||||
@@ -11,7 +11,7 @@ import { mockUsage } from "./mock_usage";
|
||||
|
||||
|
||||
|
||||
export class MockPort extends MockBackup implements AgentPort {
|
||||
export class MockPort extends MockFeedback implements AgentPort {
|
||||
private listeners = new Set<(ev: WireEvent) => void>();
|
||||
private log: WireEvent[] = [];
|
||||
// What the user has sent, so checkpoints() can mirror one per turn.
|
||||
@@ -670,7 +670,6 @@ export class MockPort extends MockBackup implements AgentPort {
|
||||
async retryQueued(itemId: string) {
|
||||
void itemId;
|
||||
}
|
||||
|
||||
async refreshQueued(itemId: string) {
|
||||
void itemId;
|
||||
}
|
||||
@@ -691,6 +690,7 @@ export class MockPort extends MockBackup implements AgentPort {
|
||||
if (at === undefined) throw new HttpError(409, "already applied", { code: "steer.already_applied" });
|
||||
window.clearTimeout(at);
|
||||
this.queued.delete(itemId);
|
||||
this.dropQueued(itemId);
|
||||
}
|
||||
|
||||
async submit(text: string) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockBackup } from "./mock_backup";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
|
||||
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
|
||||
|
||||
const ENV = {
|
||||
version: "v2.24.0", commit: "7ee4bbb", surface: "studio", os: "windows", osVersion: "10.0.19045",
|
||||
arch: "amd64", locale: "en-US", channel: "stable", providerKind: "deepseek",
|
||||
};
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
|
||||
|
||||
const item = (over: Partial<FeedbackItem> & Pick<FeedbackItem, "receipt" | "category" | "titleSnippet" | "status" | "createdAt" | "updatedAt">): FeedbackItem => ({
|
||||
needsInput: false, replies: [], unreadReplies: 0, ...over,
|
||||
});
|
||||
|
||||
const msg = (id: number, author: FeedbackReply["author"], body: string, days: number): FeedbackReply => ({ id, author, body, createdAt: ago(days) });
|
||||
|
||||
// One row per status, so the list is never drawn from a fixture that skips one.
|
||||
const SEEDED: FeedbackItem[] = [
|
||||
item({ receipt: "FB-7K3M-9QX2", category: "bug", titleSnippet: "Sidebar loses its selection after a resize", status: "fixed", issueNumber: 11350, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11350", resolvedVersion: "v2.21.0", createdAt: ago(6), updatedAt: ago(1) }),
|
||||
item({ receipt: "FB-2H8P-4WD7", category: "idea", titleSnippet: "Export a session as markdown", status: "recorded", issueNumber: 11302, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11302", createdAt: ago(4), updatedAt: ago(3) }),
|
||||
item({ receipt: "FB-5N1C-8RT3", category: "bug", titleSnippet: "Paste of a long log freezes the composer", status: "in_progress", issueNumber: 11377, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11377", createdAt: ago(3), updatedAt: ago(1),
|
||||
replies: [msg(21, "maintainer", "Thanks, reproduced. Does it also freeze with a 2 MB log, or only with the 40 MB one?", 2), msg(22, "user", "Only the 40 MB one. The 2 MB log pastes fine.", 1.5)] }),
|
||||
item({ receipt: "FB-9B4D-1XM6", category: "question", titleSnippet: "Where do I change the default model?", status: "received", createdAt: ago(0.1), updatedAt: ago(0.1) }),
|
||||
item({ receipt: "FB-3F6G-2KV9", category: "idea", titleSnippet: "Fixed in the next build: quieter update banner", status: "fixed", issueNumber: 11390, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11390", resolvedVersion: "next", createdAt: ago(8), updatedAt: ago(0.5) }),
|
||||
item({ receipt: "FB-8Q2J-6PW4", category: "other", titleSnippet: "Please support a monorepo layout", status: "wontfix", issueNumber: 11288, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11288", createdAt: ago(12), updatedAt: ago(5) }),
|
||||
item({ receipt: "FB-4T7V-3ZH1", category: "bug", titleSnippet: "Crash when opening settings on a small window", status: "duplicate", issueNumber: 11301, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11301", duplicateOf: 11299, createdAt: ago(15), updatedAt: ago(9) }),
|
||||
item({ receipt: "FB-6M9R-5CE8", category: "question", titleSnippet: "How do I move my sessions to another disk?", status: "received", statusUnavailable: true, createdAt: ago(40), updatedAt: ago(40) }),
|
||||
item({ receipt: "FB-1C5W-7NB2", category: "bug", titleSnippet: "Terminal output is cut off after a resize", status: "needs_info", needsInput: true, createdAt: ago(2), updatedAt: ago(0.2),
|
||||
replies: [msg(31, "maintainer", "Thanks for the report. Which operating system and which shell are you using?\nAnd can you say what the window size was before you resized it?", 0.2)] }),
|
||||
item({ receipt: "FB-8D3X-4LP6", category: "question", titleSnippet: "Can I use two providers at once?", status: "answered", createdAt: ago(5), updatedAt: ago(4),
|
||||
replies: [msg(11, "maintainer", "Yes: add both under Settings > Model services and pick one per session from the model menu.", 4)] }),
|
||||
item({ receipt: "FB-2Z6Q-8HK5", category: "other", titleSnippet: "Unrelated advertisement text", status: "closed", createdAt: ago(7), updatedAt: ago(7) }),
|
||||
];
|
||||
|
||||
// A tab's own sessionStorage picks the refusal the next call answers with, so a
|
||||
// dev page can show every failure without a kernel that will produce it.
|
||||
const FAULT = "rx-mock-feedback-fault";
|
||||
const REPLY_FAULT = "rx-mock-feedback-reply-fault";
|
||||
|
||||
function fault(key = FAULT): string {
|
||||
try {
|
||||
return sessionStorage.getItem(key) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS: Record<string, number> = {
|
||||
[FEEDBACK_CODE.tooLarge]: 413, [FEEDBACK_CODE.rateLimited]: 429, [FEEDBACK_CODE.disabled]: 503,
|
||||
[FEEDBACK_CODE.duplicate]: 409, [FEEDBACK_CODE.badToken]: 409, [FEEDBACK_CODE.offline]: 502, [FEEDBACK_CODE.unavailable]: 502, [FEEDBACK_CODE.internal]: 500, [FEEDBACK_CODE.busy]: 503, [FEEDBACK_CODE.imageMetadata]: 400,
|
||||
[FEEDBACK_CODE.replyLimit]: 429, [FEEDBACK_CODE.notReplyable]: 409, [FEEDBACK_CODE.challengeRequired]: 403,
|
||||
};
|
||||
|
||||
function refusal(code: string): HttpError {
|
||||
return new HttpError(STATUS[code] ?? 500, code, { code, error: code, params: code === FEEDBACK_CODE.rateLimited ? { retryAfterSeconds: 90 } : {} });
|
||||
}
|
||||
|
||||
export class MockFeedback extends MockBackup {
|
||||
private filed: FeedbackItem[] = [];
|
||||
private name = "";
|
||||
private rows: FeedbackItem[] = SEEDED.map((i) => ({ ...i, replies: [...i.replies] }));
|
||||
private seen = new Map<string, number>();
|
||||
|
||||
async feedbackEnv(): Promise<FeedbackEnv> {
|
||||
return { env: ENV, displayName: this.name, limits: LIMITS };
|
||||
}
|
||||
|
||||
async sendFeedback(req: FeedbackRequest): Promise<FeedbackReceipt> {
|
||||
const code = fault();
|
||||
if (code === "feedback.invalid") {
|
||||
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "too_long" } });
|
||||
}
|
||||
if (code && STATUS[code]) throw refusal(code);
|
||||
this.name = req.displayName;
|
||||
const receipt = "FB-" + (1000 + this.filed.length * 7).toString(36).toUpperCase().padStart(4, "K") + "-9QX2";
|
||||
const now = new Date().toISOString();
|
||||
this.filed = [item({ receipt, category: req.category, titleSnippet: req.body.trim().slice(0, 80), status: "received", createdAt: now, updatedAt: now }), ...this.filed];
|
||||
return { receipt, status: "received", createdAt: now, redacted: /sk-[A-Za-z0-9]{8,}/.test(req.body) };
|
||||
}
|
||||
|
||||
async myFeedback(): Promise<FeedbackMine> {
|
||||
const code = fault();
|
||||
if (code === "mine_error") throw new HttpError(502, "unavailable", { code: FEEDBACK_CODE.unavailable, error: "unavailable" });
|
||||
const items = [...this.filed, ...this.rows].map((i) => {
|
||||
const unreadReplies = i.replies.filter((r) => r.author === "maintainer" && r.id > (this.seen.get(i.receipt) ?? 0)).length;
|
||||
return { ...i, needsInput: i.statusUnavailable ? false : i.needsInput, unreadReplies };
|
||||
});
|
||||
const unread = items.filter((i) => i.unreadReplies > 0 || i.needsInput).length;
|
||||
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0 };
|
||||
}
|
||||
|
||||
async replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt> {
|
||||
const code = fault(REPLY_FAULT);
|
||||
if (code === "feedback.invalid") {
|
||||
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "empty" } });
|
||||
}
|
||||
if (code) throw refusal(code);
|
||||
const row = this.rows.find((i) => i.receipt === receipt);
|
||||
if (!row) throw refusal(FEEDBACK_CODE.notReplyable);
|
||||
this.seen.set(receipt, Math.max(0, ...row.replies.map((r) => r.id)));
|
||||
const replyId = Math.max(0, ...row.replies.map((r) => r.id)) + 1;
|
||||
const createdAt = new Date().toISOString();
|
||||
row.replies.push({ id: replyId, author: "user", body, createdAt });
|
||||
row.updatedAt = createdAt;
|
||||
row.needsInput = false;
|
||||
if (row.status === "needs_info") row.status = "received";
|
||||
return { replyId, createdAt };
|
||||
}
|
||||
|
||||
async feedbackSeen(receipt: string, upTo: number): Promise<void> {
|
||||
if (this.rows.some((i) => i.receipt === receipt)) this.seen.set(receipt, Math.max(this.seen.get(receipt) ?? 0, upTo));
|
||||
}
|
||||
}
|
||||
@@ -182,12 +182,23 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve<TreeWorkspace>({ root: path, name: path.split("/").pop() ?? path, remembered: true, sessions: [] });
|
||||
}
|
||||
|
||||
async revealWorkspace() {}
|
||||
|
||||
removeWorkspace(path: string) {
|
||||
const at = this.roots.indexOf(path);
|
||||
if (at >= 0) this.roots.splice(at, 1);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
moveWorkspace(path: string, direction: -1 | 1) {
|
||||
const at = this.roots.indexOf(path);
|
||||
const to = at + direction;
|
||||
if (at >= 0 && to >= 0 && to < this.roots.length) {
|
||||
[this.roots[at], this.roots[to]] = [this.roots[to], this.roots[at]];
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
removeSession(_path: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -213,6 +224,10 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0 });
|
||||
}
|
||||
|
||||
hostCapabilities() {
|
||||
return Promise.resolve({ pickFolder: true, addWorkspace: true });
|
||||
}
|
||||
|
||||
pickFolder() {
|
||||
return Promise.resolve<string | null>("~/projects/mock-workspace");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ModelEffort,
|
||||
ModelLimit,
|
||||
Protocol,
|
||||
ProviderCheck,
|
||||
ProviderEdit,
|
||||
@@ -45,6 +46,9 @@ export class MockProvider extends MockBoundary {
|
||||
visionModels: ["gpt-4o"], canSetVision: true,
|
||||
reasoningProtocol: "openai", supportedEfforts: ["low", "medium", "high"],
|
||||
modelEfforts: { "claude-sonnet-4": { supportedEfforts: ["low", "high", "max"], defaultEffort: "high" } },
|
||||
contextWindow: 131072,
|
||||
modelLimits: { "claude-sonnet-4": { contextWindow: 200000, maxOutputTokens: 64000 } },
|
||||
inheritedLimits: { "gpt-4o": { contextWindow: 131072 }, "claude-sonnet-4": { contextWindow: 131072 } },
|
||||
inheritedEfforts: {
|
||||
"gpt-4o": { supportedEfforts: ["low", "medium", "high"] },
|
||||
"claude-sonnet-4": { supportedEfforts: ["low", "medium", "high"] },
|
||||
@@ -146,6 +150,7 @@ export class MockProvider extends MockBoundary {
|
||||
reasoningProtocol: edit.reasoningProtocol, supportedEfforts: edit.supportedEfforts,
|
||||
defaultEffort: edit.defaultEffort,
|
||||
modelEfforts: edit.modelEfforts ? mockModelEfforts(edit.modelEfforts) : p.modelEfforts,
|
||||
modelLimits: edit.modelLimits ? mockModelLimits(edit.modelLimits) : p.modelLimits,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
@@ -179,3 +184,8 @@ function mockModelEfforts(sent: Record<string, ModelEffort>): Record<string, Mod
|
||||
const kept = Object.entries(sent).filter(([, v]) => v.supportedEfforts.length > 0);
|
||||
return kept.length ? Object.fromEntries(kept) : undefined;
|
||||
}
|
||||
|
||||
function mockModelLimits(sent: Record<string, ModelLimit>): Record<string, ModelLimit> | undefined {
|
||||
const kept = Object.entries(sent).filter(([, v]) => v.contextWindow || v.maxOutputTokens);
|
||||
return kept.length ? Object.fromEntries(kept) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SsePort } from "./sse";
|
||||
import { SseExtensions } from "./sse_ext";
|
||||
import { HttpError } from "./http_error";
|
||||
import { host } from "./host";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear(); });
|
||||
|
||||
const root = "/rt/project";
|
||||
const path = "/plugins/notes-kit/export";
|
||||
const callers = [
|
||||
["Studio", () => new SsePort(root)],
|
||||
["extension port", () => new SseExtensions(root)],
|
||||
] as const;
|
||||
const replies = [
|
||||
{ name: "export refusal", status: 422, body: { code: "plugin.export_failed", error: "archive cannot be written", params: { detail: "installed folder missing" } } },
|
||||
{ name: "unknown package", status: 404, body: { code: "plugin.not_installed", error: "that plugin is not installed" } },
|
||||
{ name: "plain text", status: 503, body: "export service unavailable" },
|
||||
{ name: "empty response", status: 502, body: "" },
|
||||
] as const;
|
||||
|
||||
it.each(callers.flatMap(([caller, make]) => replies.map((reply) => ({ caller, make, ...reply }))))(
|
||||
"keeps a $name from the $caller export endpoint", async ({ make, status, body }) => {
|
||||
const raw = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const response = new Response(raw, { status });
|
||||
const fetch = vi.fn().mockResolvedValue(response);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const save = vi.spyOn(host(), "saveBytes");
|
||||
const readArchive = vi.spyOn(response, "blob");
|
||||
const error = await make().exportPlugin("notes-kit").catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
const typed = error as HttpError;
|
||||
expect(typed.status).toBe(status);
|
||||
expect(typed.message).toBe(typeof body === "string" ? body || path + ": " + status : body.error);
|
||||
expect(typed.reason).toEqual(typeof body === "string" ? undefined : body);
|
||||
expect(typed.detailed).toBe(raw !== "");
|
||||
expect(fetch).toHaveBeenCalledExactlyOnceWith(root + path, { credentials: "same-origin" });
|
||||
expect(readArchive).not.toHaveBeenCalled();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(response.bodyUsed).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(callers)("renders the %s export refusal with the kernel's detail", async (_caller, make) => {
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(replies[0].body), { status: 422 })));
|
||||
const error = await make().exportPlugin("notes-kit").catch((e: unknown) => e);
|
||||
expect(reason(error)).toBe("未能导出该插件:installed folder missing");
|
||||
});
|
||||
|
||||
const archive = new Uint8Array([80, 75, 3, 4]);
|
||||
|
||||
function exportResponse() {
|
||||
return new Response(archive, { headers: { "content-type": "application/zip", "X-Reasonix-Required-Env": "NOTES_TOKEN,DOCS_KEY" } });
|
||||
}
|
||||
|
||||
it.each(["/exports/notes-kit.zip", ""])("keeps the native archive save answer %j", async (savedTo) => {
|
||||
const response = exportResponse();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
const save = vi.spyOn(host(), "saveBytes").mockResolvedValue(savedTo);
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
expect(await new SsePort(root).exportPlugin("notes-kit")).toEqual({
|
||||
required: ["NOTES_TOKEN", "DOCS_KEY"], savedTo: savedTo || undefined,
|
||||
});
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith("notes-kit.zip", archive);
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
expect(response.bodyUsed).toBe(true);
|
||||
});
|
||||
|
||||
it.each(callers)("keeps the %s browser archive download", async (_caller, make) => {
|
||||
const response = exportResponse();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
vi.spyOn(host(), "saveBytes").mockResolvedValue(null);
|
||||
const create = vi.fn().mockReturnValue("blob:notes-archive");
|
||||
const revoke = vi.fn();
|
||||
vi.stubGlobal("URL", class extends URL {
|
||||
static createObjectURL = create;
|
||||
static revokeObjectURL = revoke;
|
||||
});
|
||||
let clicked!: HTMLAnchorElement;
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked = this; });
|
||||
expect(await make().exportPlugin("notes-kit")).toEqual({ required: ["NOTES_TOKEN", "DOCS_KEY"] });
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(new Uint8Array(await create.mock.calls[0][0].arrayBuffer())).toEqual(archive);
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(clicked.href).toBe("blob:notes-archive");
|
||||
expect(clicked.download).toBe("notes-kit.zip");
|
||||
expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:notes-archive");
|
||||
});
|
||||
@@ -40,11 +40,13 @@ import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketP
|
||||
import type { Appearance, ThemeImport, ThemePack } from "./look";
|
||||
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
|
||||
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
|
||||
export type { ModelEffort, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
|
||||
export type { ModelEffort, ModelLimit, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { FeedbackEnv, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
|
||||
export type * from "./plugin";
|
||||
export type * from "./market";
|
||||
export type * from "./feedback";
|
||||
|
||||
// GET /mcp. One external tool provider. state is ready | connecting | failed |
|
||||
// disabled | idle: disabled is switched off and stays off across restarts, idle
|
||||
@@ -113,6 +115,8 @@ export interface QueueItem {
|
||||
createdAt: string;
|
||||
// Why a blocked entry stopped, from the kernel that stopped it.
|
||||
blockReason?: string;
|
||||
// The stable identity of that stop; the sentence is worded from it here.
|
||||
blockCode?: string;
|
||||
// Files this entry froze at the moment it was queued. Their presence is what
|
||||
// makes re-freezing meaningful; the entry quotes them as they were.
|
||||
refs?: { path?: string }[];
|
||||
@@ -348,6 +352,16 @@ export interface AgentPort {
|
||||
deleteBackup(id: string): Promise<void>;
|
||||
previewBackup(id: string, passphrase: string): Promise<BackupPlan>;
|
||||
applyBackup(planId: string, items: string[], consented: string[]): Promise<BackupApplyResult>;
|
||||
// A report becomes public only if the maintainers file it as an issue.
|
||||
// Refusals carry feedback.* codes; only feedback.offline is safe to retry
|
||||
// with the same idempotencyKey. A reply has no key and is never retried by
|
||||
// the kernel.
|
||||
feedbackEnv(locale?: string): Promise<FeedbackEnv>;
|
||||
sendFeedback(req: FeedbackRequest): Promise<FeedbackReceipt>;
|
||||
myFeedback(): Promise<FeedbackMine>;
|
||||
replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt>;
|
||||
// Records that the thread was shown up to reply upTo, so those replies stop counting as new.
|
||||
feedbackSeen(receipt: string, upTo: number): Promise<void>;
|
||||
workspaces(): Promise<WorkspaceInfo>;
|
||||
// Rebuilds the whole runtime against another folder. The conversation does
|
||||
// not come along, so the caller has to reload the transcript afterwards.
|
||||
|
||||
@@ -49,6 +49,10 @@ export interface ProviderEntry {
|
||||
modelEfforts?: Record<string, ModelEffort>;
|
||||
inheritedEfforts?: Record<string, ModelEffort>;
|
||||
modelProtocols?: Record<string, string>;
|
||||
// Per model: the window and output cap it declares for itself, and the ones
|
||||
// it inherits when it declares none.
|
||||
modelLimits?: Record<string, ModelLimit>;
|
||||
inheritedLimits?: Record<string, ModelLimit>;
|
||||
// Removing the one in use would leave the session on a model that no longer
|
||||
// resolves, so the row offers no delete.
|
||||
inUse: boolean;
|
||||
@@ -176,6 +180,15 @@ export interface ProviderEdit {
|
||||
// The whole per-model answer for the listed models: a model left out
|
||||
// inherits the connection's levels again. Omitted leaves every model alone.
|
||||
modelEfforts?: Record<string, ModelEffort>;
|
||||
// The whole per-model answer for the listed models: a model left out, or sent
|
||||
// empty, inherits the connection's limits again. Omitted leaves every model alone.
|
||||
modelLimits?: Record<string, ModelLimit>;
|
||||
}
|
||||
|
||||
// One model's own context window and output cap; an absent field inherits.
|
||||
export interface ModelLimit {
|
||||
contextWindow?: number;
|
||||
maxOutputTokens?: number;
|
||||
}
|
||||
|
||||
// One model's effort vocabulary and the level auto resolves to; no default is
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
|
||||
import { SseBackup } from "./sse_backup";
|
||||
import { SseFeedback } from "./sse_feedback";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { host } from "./host";
|
||||
@@ -16,7 +16,7 @@ import { openLiveStream } from "./livestream";
|
||||
// a load. The read is one small JSON body and answers from memory.
|
||||
const UPDATE_POLL_MS = 500;
|
||||
|
||||
export class SsePort extends SseBackup implements AgentPort {
|
||||
export class SsePort extends SseFeedback implements AgentPort {
|
||||
status() {
|
||||
return this.get<SessionStatus>("/status");
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export class SsePort extends SseBackup implements AgentPort {
|
||||
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`/plugins/${name}/export: ${res.status}`);
|
||||
if (!res.ok) await SsePort.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
|
||||
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
|
||||
const blob = await res.blob();
|
||||
// A shell with a save dialog puts the archive where it is asked to; the
|
||||
@@ -371,9 +371,8 @@ export class SsePort extends SseBackup implements AgentPort {
|
||||
if (why) throw new HttpError(0, why.error || "not shown", why, !!(why.code || why.error));
|
||||
}
|
||||
|
||||
openExternal(url: string): Promise<void> {
|
||||
host().openExternal(url);
|
||||
return Promise.resolve();
|
||||
async openExternal(url: string): Promise<void> {
|
||||
await host().openExternal(url);
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
|
||||
@@ -88,7 +88,7 @@ export class SseExtensions extends SseLook {
|
||||
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`/plugins/${name}/export: ${res.status}`);
|
||||
if (!res.ok) await SseExtensions.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
|
||||
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
|
||||
const url = URL.createObjectURL(await res.blob());
|
||||
const a = document.createElement("a");
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SseBackup } from "./sse_backup";
|
||||
import type { FeedbackEnv, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
|
||||
export class SseFeedback extends SseBackup {
|
||||
feedbackEnv(locale?: string) {
|
||||
return this.get<FeedbackEnv>("/feedback/env" + (locale ? "?locale=" + encodeURIComponent(locale) : ""));
|
||||
}
|
||||
sendFeedback(req: FeedbackRequest) {
|
||||
return this.post0<FeedbackReceipt>("/feedback", req);
|
||||
}
|
||||
myFeedback() {
|
||||
return this.get<FeedbackMine>("/feedback/mine");
|
||||
}
|
||||
replyFeedback(receipt: string, body: string) {
|
||||
return this.post0<FeedbackReplyReceipt>("/feedback/" + encodeURIComponent(receipt) + "/reply", { body });
|
||||
}
|
||||
feedbackSeen(receipt: string, upTo: number) {
|
||||
return this.post("/feedback/" + encodeURIComponent(receipt) + "/seen", { upTo });
|
||||
}
|
||||
}
|
||||
@@ -281,6 +281,9 @@ export interface AskOrigin {
|
||||
source: string;
|
||||
message?: string;
|
||||
note?: string;
|
||||
url?: string;
|
||||
urlHost?: string;
|
||||
urlLocal?: boolean;
|
||||
}
|
||||
|
||||
export interface Ask {
|
||||
@@ -645,6 +648,7 @@ export type ExecutionGraphRead = ExecutionGraphView & ExecutionGraphSnapshot;
|
||||
|
||||
export interface WireEvent {
|
||||
kind: Kind;
|
||||
nonPersistable?: boolean;
|
||||
text?: string;
|
||||
detail?: string;
|
||||
code?: string;
|
||||
|
||||
@@ -12,11 +12,27 @@ import { nextId } from "./ids";
|
||||
// sealTurn tell the two apart.
|
||||
const merge = (prev: Tool, next: Tool): Tool => ({ ...prev, ...next, partial: next.partial ?? false });
|
||||
|
||||
// The kernel's reserved ToolProgress channels for a child's live preview. They
|
||||
// describe the child, never the call that spawned it, so they are kept apart
|
||||
// from the call's card: folding one in renames it and reopens it.
|
||||
export const SUBAGENT_PROGRESS_PREFIX = "reasonix.subagent.";
|
||||
export const isSubagentProgress = (name: string | undefined): boolean => !!name?.startsWith(SUBAGENT_PROGRESS_PREFIX);
|
||||
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
|
||||
export function notePhase(phases: Record<string, string>, tool: Tool): Record<string, string> {
|
||||
if (tool.name !== SUBAGENT_PROGRESS_PREFIX + "status" || !tool.id) return phases;
|
||||
return { ...phases, [tool.id]: tool.output ?? "" };
|
||||
}
|
||||
/** A child is working until its tracker says otherwise. */
|
||||
export const subagentLive = (phase: string | undefined): boolean => !!phase && !TERMINAL.has(phase);
|
||||
|
||||
export function foldTool(items: Item[], tool: Tool, running: boolean): Item[] {
|
||||
// A subagent's calls carry parentId; they belong inside the task that spawned
|
||||
// them, not as siblings in the main flow.
|
||||
// them, not as siblings in the main flow. A fleet worker's parent is itself a
|
||||
// child card, so the owning card is the one that holds the parent as a child.
|
||||
if (tool.parentId) {
|
||||
const at = items.findIndex((i) => i.t === "tool" && i.tool.id === tool.parentId);
|
||||
const at = items.findIndex(
|
||||
(i) => i.t === "tool" && (i.tool.id === tool.parentId || i.children.some((c) => c.id === tool.parentId)),
|
||||
);
|
||||
if (at >= 0) {
|
||||
const parent = items[at] as Extract<Item, { t: "tool" }>;
|
||||
const kids = parent.children.slice();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Tool } from "../port/wire";
|
||||
import { foldTool } from "./fold";
|
||||
import type { Item } from "./session_types";
|
||||
|
||||
const tool = (id: string, name: string, parentId?: string): Tool => ({ id, name, parentId, readOnly: true });
|
||||
const run = (events: Tool[]): Item[] => events.reduce<Item[]>((items, t) => foldTool(items, t, true), []);
|
||||
|
||||
describe("a delegate's calls stay inside the card that spawned them", () => {
|
||||
it("nests a task's own call under the task", () => {
|
||||
const items = run([tool("task", "use_capability"), tool("task/ls", "ls", "task")]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ t: "tool", children: [{ id: "task/ls" }] });
|
||||
});
|
||||
|
||||
it("keeps a fleet worker's call out of the main list", () => {
|
||||
const items = run([
|
||||
tool("fl", "use_capability"),
|
||||
tool("fl/fleet-1", "task", "fl"),
|
||||
tool("fl/fleet-1/read", "read_file", "fl/fleet-1"),
|
||||
]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ t: "tool" });
|
||||
const kids = (items[0] as Extract<Item, { t: "tool" }>).children;
|
||||
expect(kids.map((c) => c.id)).toEqual(["fl/fleet-1", "fl/fleet-1/read"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SessionState } from "./session_types";
|
||||
|
||||
// The kernel answers a queued line with the id it queued it under. The row is
|
||||
// already on screen by then: this gives it a name to be taken back by.
|
||||
export function nameQueued(s: SessionState, id: string, itemId: string, queued: "steer" | "followup"): SessionState {
|
||||
// Guidance read at a tool boundary can be reported before its receipt lands
|
||||
// here. That report drew the delivered line, so this row is its duplicate and
|
||||
// naming it would re-open a line that already arrived.
|
||||
if (s.items.some((i) => i.t === "user" && i.itemId === itemId && i.id !== id)) {
|
||||
return { ...s, items: s.items.filter((i) => !(i.t === "user" && i.id === id)) };
|
||||
}
|
||||
return {
|
||||
...s,
|
||||
items: s.items.map((i) => (i.t === "user" && i.id === id ? { ...i, itemId, queued, pending: true } : i)),
|
||||
};
|
||||
}
|
||||
@@ -93,6 +93,22 @@ describe("sealing a turn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The kernel flags a turn the user's own action ended. Its err is the sentinel's
|
||||
// wording, which says nothing the flag does not, so it is not a failure card.
|
||||
describe("a turn the user cancelled", () => {
|
||||
it("does not draw the cancellation as an error card", () => {
|
||||
const s = run([partial("c1"), { kind: "turn_done", cancelled: true, err: "context canceled" } as SessionEvent]);
|
||||
expect(s.items.filter((i) => i.t === "notice" && i.level === "error")).toHaveLength(0);
|
||||
expect(s.terminal).toEqual({ kind: "cancelled" });
|
||||
expect(s.doing).toBe("已取消");
|
||||
});
|
||||
|
||||
it("still draws a genuine failure", () => {
|
||||
const s = run([{ kind: "turn_done", err: "provider down" } as SessionEvent]);
|
||||
expect(notices(s)).toEqual(["provider down"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebuilding a reopened transcript", () => {
|
||||
const users = (msgs: HistoryMessage[]) =>
|
||||
fromHistory(msgs).items.filter((i): i is Extract<Item, { t: "user" }> => i.t === "user");
|
||||
@@ -568,3 +584,21 @@ describe("which model wrote a reply", () => {
|
||||
expect(says(st)[0].model).toBe("deepseek/deepseek-pro");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a receipt that arrives after the delivery it is for", () => {
|
||||
const sent = { kind: "__user", text: "换个思路", pending: true, id: "row-1" } as SessionEvent;
|
||||
const delivered = { kind: "steer", text: "换个思路", itemId: "it1" } as SessionEvent;
|
||||
const receipt = { kind: "__queued", id: "row-1", itemId: "it1", queued: "steer" } as SessionEvent;
|
||||
const users = (st: SessionState) => st.items.filter((i): i is Extract<Item, { t: "user" }> => i.t === "user");
|
||||
|
||||
it("leaves one delivered line and nothing waiting", () => {
|
||||
const rows = users(run([sent, delivered, receipt]));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ steer: true, itemId: "it1" });
|
||||
expect(rows.some((r) => r.pending)).toBe(false);
|
||||
});
|
||||
|
||||
it("still names a line the kernel has not delivered yet", () => {
|
||||
expect(users(run([sent, receipt]))[0]).toMatchObject({ pending: true, itemId: "it1" });
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,8 @@ import { nameTurnStart, othersSteer } from "./turn_start";
|
||||
import { appendText, foldMessage, sealSay } from "./say";
|
||||
import { nextId } from "./ids";
|
||||
import { foldStall } from "./stall";
|
||||
import { dropTool, foldLastRead, foldTool, mergeReads } from "./fold";
|
||||
import { nameQueued } from "./queued";
|
||||
import { dropTool, foldLastRead, foldTool, isSubagentProgress, mergeReads, notePhase } from "./fold";
|
||||
export { quoteAmount };
|
||||
export { setShowsReceipt, showsReceipt };
|
||||
|
||||
@@ -51,7 +52,7 @@ export const initialState: SessionState = {
|
||||
waiting: {},
|
||||
running: false,
|
||||
doing: "空闲",
|
||||
steerQueue: [],
|
||||
steerQueue: [], subagentPhase: {},
|
||||
awaitingTurnStart: [],
|
||||
queueMoved: 0,
|
||||
browserTabsMoved: 0,
|
||||
@@ -107,7 +108,8 @@ function sealTurn(items: Item[], err?: string): Item[] {
|
||||
}
|
||||
if (!err) return sealed;
|
||||
// The kernel's own words: classifying them here would be this file guessing
|
||||
// at failures it cannot see.
|
||||
// at failures it cannot see. A turn it flagged as cancelled never gets here:
|
||||
// the flag is the identity, and its err only repeats the sentinel.
|
||||
return [...sealed, { t: "notice" as const, id: nextId(), level: "error", text: err }];
|
||||
}
|
||||
|
||||
@@ -232,17 +234,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
items: [...s.items, { t: "user", id, text: ev.text, pending: ev.pending }],
|
||||
};
|
||||
}
|
||||
// The kernel answers a queued line with the id it queued it under. The row
|
||||
// is already on screen by then — this is what gives it a name to be taken
|
||||
// back by, and losing that name is the same as losing the button.
|
||||
if (ev.kind === "__queued") {
|
||||
return {
|
||||
...s,
|
||||
items: s.items.map((i) =>
|
||||
i.t === "user" && i.id === ev.id ? { ...i, itemId: ev.itemId, queued: ev.queued, pending: true } : i,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (ev.kind === "__queued") return nameQueued(s, ev.id, ev.itemId, ev.queued);
|
||||
// A line the kernel never took is not part of what happened, so it leaves the
|
||||
// transcript rather than sitting there looking sent. Either name identifies
|
||||
// it: the row the composer minted, or the entry the kernel queued it as —
|
||||
@@ -375,6 +367,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
: s;
|
||||
|
||||
case "tool_progress":
|
||||
if (ev.tool && isSubagentProgress(ev.tool.name)) return { ...s, subagentPhase: notePhase(s.subagentPhase, ev.tool) };
|
||||
return ev.tool
|
||||
? { ...s, executions: noteTool(s.executions, ev.tool, false), items: foldTool(s.items, ev.tool, true) }
|
||||
: s;
|
||||
@@ -607,10 +600,10 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
...s,
|
||||
running: false,
|
||||
terminal: turnTerminal(ev),
|
||||
doing: ev.outcome === "no_progress" ? "已暂停" : ev.err ? "已中断" : "已完成",
|
||||
doing: ev.outcome === "no_progress" ? "已暂停" : ev.cancelled ? "已取消" : ev.err ? "已中断" : "已完成",
|
||||
waiting: {},
|
||||
plan: livePlan(s.plan),
|
||||
items: withReceipt(sealTurn(sealSay(s.items, true), ev.outcome === "no_progress" ? undefined : ev.err), ev.receipt),
|
||||
items: withReceipt(sealTurn(sealSay(s.items, true), ev.outcome === "no_progress" || ev.cancelled ? undefined : ev.err), ev.receipt),
|
||||
};
|
||||
|
||||
default:
|
||||
@@ -652,7 +645,7 @@ function withReceipt(items: Item[], r?: Receipt): Item[] {
|
||||
// eleven. The server strips these before /history now — this is what covers
|
||||
// sessions already on disk.
|
||||
const CONTROL =
|
||||
/<(reasoning-language|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|project-instructions|capability-route|interrupted-turn-recovery|workspace)[\s\S]*?<\/\1>\s*/g;
|
||||
/<(reasoning-language|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|project-instructions|capability-route|interrupted-turn-recovery|workspace|scheduled-run)[\s\S]*?<\/\1>\s*/g;
|
||||
const stripControl = (s: string) => s.replace(CONTROL, "").trim();
|
||||
|
||||
// A plan that ran to the end is spent: struck through in the rail it reads as
|
||||
|
||||
@@ -243,4 +243,7 @@ export interface SessionState {
|
||||
// are kept apart from `views` because they have no place of their own: they
|
||||
// appear only where the thing they stand in for appears.
|
||||
takeovers: Record<string, ExtensionSurface>;
|
||||
// The last phase each sub-agent reported, by call id. Kept beside the cards
|
||||
// because a background child outlives the call that started it.
|
||||
subagentPhase: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialState, reduce, type Item, type SessionEvent } from "./session";
|
||||
import go from "../../../../internal/contract/event/subagent_progress.go?raw";
|
||||
import { SUBAGENT_PROGRESS_PREFIX } from "./fold";
|
||||
import { railOf } from "../ui/panels/derive";
|
||||
|
||||
const prof = { name: "flash-fill" };
|
||||
const dispatch = (id: string): SessionEvent =>
|
||||
({ kind: "tool_dispatch", tool: { id, name: "use_capability", args: "{}", profile: prof } }) as SessionEvent;
|
||||
const started = (id: string): SessionEvent =>
|
||||
({ kind: "tool_result", tool: { id, name: "use_capability", output: 'Started background task "task-1"', profile: prof } }) as SessionEvent;
|
||||
const status = (id: string, phase: string, parentId?: string): SessionEvent =>
|
||||
({ kind: "tool_progress", tool: { id, parentId, name: "reasonix.subagent.status", output: phase, durationMs: phase === "completed" ? 41000 : undefined } }) as SessionEvent;
|
||||
|
||||
const after = (evs: SessionEvent[]) => evs.reduce(reduce, initialState);
|
||||
const card = (s: ReturnType<typeof after>) => s.items.find((i): i is Extract<Item, { t: "tool" }> => i.t === "tool")!;
|
||||
const liveTasks = (s: ReturnType<typeof after>) => railOf(s.items, s.executions, s.subagentPhase).tasks.filter((x) => x.running).length;
|
||||
|
||||
describe("a background sub-agent after its dispatching call has answered", () => {
|
||||
it("is still running while its last phase is not terminal", () => {
|
||||
const s = after([dispatch("c1"), started("c1"), status("c1", "running"), status("c1", "tool")]);
|
||||
expect(card(s).running).toBe(false);
|
||||
expect(liveTasks(s)).toBe(1);
|
||||
});
|
||||
|
||||
it("is done once a terminal phase arrives, and the card is not reopened", () => {
|
||||
const s = after([dispatch("c1"), started("c1"), status("c1", "running"), status("c1", "completed")]);
|
||||
expect(card(s).running).toBe(false);
|
||||
expect(liveTasks(s)).toBe(0);
|
||||
});
|
||||
|
||||
it("is done on failure and on cancellation too", () => {
|
||||
for (const phase of ["failed", "cancelled"]) {
|
||||
expect(liveTasks(after([dispatch("c1"), started("c1"), status("c1", "running"), status(("c1"), phase)]))).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("never renames the call or its recorded execution", () => {
|
||||
const s = after([dispatch("c1"), started("c1"), status("c1", "completed")]);
|
||||
expect(card(s).tool.name).toBe("use_capability");
|
||||
expect(s.executions["c1"]).toMatchObject({ name: "use_capability" });
|
||||
});
|
||||
|
||||
it("keeps a call that is still open running", () => {
|
||||
expect(liveTasks(after([dispatch("c1"), status("c1", "completed")]))).toBe(1);
|
||||
});
|
||||
|
||||
it("draws no card for a preview", () => {
|
||||
expect(after([status("c1", "queued")]).items).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows a fleet worker through the card that holds it", () => {
|
||||
const fleet = { kind: "tool_dispatch", tool: { id: "fl", name: "use_capability", profile: { name: "fleet", count: 2 } } } as SessionEvent;
|
||||
const worker = { kind: "tool_dispatch", tool: { id: "fl/w1", parentId: "fl", name: "task" } } as SessionEvent;
|
||||
const closed = { kind: "tool_result", tool: { id: "fl", name: "use_capability", profile: { name: "fleet", count: 2 } } } as SessionEvent;
|
||||
const mid = after([fleet, worker, closed, status("fl/w1", "running", "fl")]);
|
||||
expect(liveTasks(mid)).toBe(1);
|
||||
expect(liveTasks(after([fleet, worker, closed, status("fl/w1", "running", "fl"), status("fl/w1", "completed", "fl")]))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reserved prefix", () => {
|
||||
it("is the kernel's", () => {
|
||||
expect(go.match(/SubagentProgressPrefix\s*=\s*"([^"]+)"/)?.[1]).toBe(SUBAGENT_PROGRESS_PREFIX);
|
||||
});
|
||||
});
|
||||
@@ -404,14 +404,11 @@ rules are written in.
|
||||
- `[data-k="me"]` opens a turn. Scrolling back, it is the only anchor, and it
|
||||
used to be as light as a tool call's header. The turn number is deliberately
|
||||
absent: the rewind entry is on the card, so there is no number to match by eye.
|
||||
- The bubble sizes the card; the controls row above it does not.
|
||||
`contain: inline-size` keeps the row out of the card's width, and
|
||||
`margin-inline-start: auto` holds the bubble's right edge on the column's.
|
||||
- When the labels need more room than the bubble, the controls drop to icons
|
||||
and keep their names, hints and focus. Fit is measured (`labelfit.ts`).
|
||||
- No container-query breakpoint decides it: the labels' width moves with the
|
||||
language and the marks beside them, so any fixed width is wrong for a row.
|
||||
- Icons wider than the bubble overflow to the left, where the free space is.
|
||||
- User message controls sit below the bubble, ordered copy, edit, rewind.
|
||||
They stay icon-only with names, hints and keyboard focus. Inline-size
|
||||
containment keeps the row from widening the bubble; wider rows extend left.
|
||||
- The row appears on message hover, keyboard focus, or an open rewind menu.
|
||||
Devices without hover keep the controls visible.
|
||||
- `[data-k="host"]` is what the host did itself. A transcript has three authors —
|
||||
you, the model, the host — and `data-k` only knows tool names, so the host's
|
||||
cards landed on the default grey beside an uncategorised tool. A dashed line is
|
||||
@@ -670,9 +667,36 @@ what exists.
|
||||
|
||||
## Onboarding
|
||||
|
||||
- The stage is the named inline-size container `onboarding`. The card folds at
|
||||
760px of that container, so interface zoom changes its columns and spacing.
|
||||
- The fold follows available CSS width, not the unscaled viewport measured by
|
||||
a media query. Form controls must fit inside the shell's clipping boundary.
|
||||
- `perf/onboarding.mjs` checks card bounds, the 760/761px boundary, saved zoom
|
||||
and resizing, alongside scrolling, focus clearance and completing setup.
|
||||
- The connect card grows inside the opening scene rather than starting a second
|
||||
screen, so the introduction above it stays present and the palette follows the
|
||||
scene (a dark ground) rather than the app theme.
|
||||
- The stage is exactly the window's height and is the scroll container, because
|
||||
the body clips.
|
||||
- That height is `100dvh / var(--zoom)`, as on the body: `vh` does not scale
|
||||
with the zoom setting, so a bare `100dvh` stage would be taller than the
|
||||
zoomed window.
|
||||
- The title bar is `position: sticky; top: 0` inside the stage: it holds the
|
||||
drag region and the window buttons, which must stay on screen however far
|
||||
the card is scrolled.
|
||||
- A stage with only a `min-height` grows with its content and never overflows
|
||||
itself, so the connected state pushed the start button below a short window
|
||||
with nothing to scroll.
|
||||
- The ambient glow is `position: fixed`: absolutely positioned inside a scroll
|
||||
container it counts as scrollable overflow, and at a wide window (38vw tall)
|
||||
it let the stage scroll far past the card.
|
||||
- The shell is `flex: none`: a shrinkable shell is cut off instead of making
|
||||
the stage scroll.
|
||||
- The stage's `scroll-padding-top` equals the title bar's 64px, so focus moving
|
||||
up the form scrolls the field below the sticky bar rather than under it.
|
||||
- The title bar and footer are `flex: none` too; as shrinkable flex items they
|
||||
were squeezed to half height once the card overflowed.
|
||||
- The shell clips with `overflow: clip`, so it is not a scroll container.
|
||||
- Its labels are not uppercased: tracking pulls apart the Chinese particles in
|
||||
mixed text. The "get a key" entry sits at the label's right end with tracking
|
||||
zeroed, because the label's .06em is for Latin small labels.
|
||||
@@ -779,6 +803,14 @@ what exists.
|
||||
- Remove is the one irreversible action in a row, so it does not look like the
|
||||
other buttons and appears only on hover.
|
||||
|
||||
## Community
|
||||
|
||||
- The QR code sits on a white plate in both themes: a scanner needs dark modules
|
||||
on a light field with a quiet zone, and an inverted code in the dark theme
|
||||
does not scan. The plate is the image's own quiet zone, not a card behind it.
|
||||
- The image is drawn at 5 CSS pixels per module (185px for 37 modules) so the
|
||||
squares land on whole pixels; `image-rendering: pixelated` keeps them hard.
|
||||
|
||||
## Version list
|
||||
|
||||
- The list is one grid and each row a subgrid of it, so version, description,
|
||||
@@ -935,6 +967,9 @@ what exists.
|
||||
under — the second is the writer's taxonomy and means nothing to a reader. The
|
||||
dot on the left is the one pixel on this page that matters: it joins a memory
|
||||
to the behaviour just observed.
|
||||
- The title is the one part of a row nobody bounds, so it wraps inside a share of
|
||||
the row and the description gives way to it; the scope, date and action keep
|
||||
their width and never break.
|
||||
- Older versions are indented and one tier darker so they read as what is behind
|
||||
the current one rather than as more memories beside it.
|
||||
- The write card uses the memory colour rather than a tool colour: this step
|
||||
|
||||
@@ -2766,6 +2766,7 @@
|
||||
font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
|
||||
.srv .acts { flex: none }
|
||||
.srv .why { margin: 4px 0 0 15px; font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--err) }
|
||||
.srv .why[role="status"] { color: var(--muted) }
|
||||
.srv .peek { margin: 7px 0 1px 15px }
|
||||
|
||||
.srv .srv-ab { display: flex; align-items: baseline; gap: 8px; margin: 5px 0 0 15px }
|
||||
@@ -2808,6 +2809,7 @@
|
||||
}
|
||||
.act:hover:not(:disabled) { background: var(--overlay); border-color: var(--muted) }
|
||||
.act:disabled { color: var(--ghost); cursor: default }
|
||||
.srv .act:disabled { opacity: .45 }
|
||||
.act:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
|
||||
|
||||
.reload { display: inline-flex; align-items: center; gap: 6px }
|
||||
@@ -2860,6 +2862,7 @@
|
||||
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.7 var(--mono); resize: vertical;
|
||||
}
|
||||
.addsrv .paste::placeholder, .addpkg .paste::placeholder { color: var(--ghost); white-space: pre-line }
|
||||
.addpkg .paste:disabled { background: var(--overlay); cursor: default }
|
||||
.addsrv .paste:focus, .addpkg .paste:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.addsrv .acts, .addpkg .acts { display: flex; align-items: center; gap: 8px; margin-top: 10px }
|
||||
.addsrv .acts .note, .addpkg .acts .note { flex: 1; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
|
||||
@@ -3098,6 +3101,12 @@
|
||||
.addp .fields label { display: flex; flex-direction: column; gap: 4px }
|
||||
.addp .fields label.grow { flex: 1; min-width: 150px }
|
||||
.addp .fields label.full { flex-basis: 100% }
|
||||
.addp .fields input[aria-invalid="true"] { border-color: var(--err) }
|
||||
.addp .fields .name-cell { flex: 1; min-width: 150px; display: flex; flex-direction: column; gap: 4px }
|
||||
.addp .fields .name-cell label { display: flex; flex-direction: column; gap: 4px }
|
||||
.addp .fields input[aria-invalid="true"]:focus { outline-color: var(--err) }
|
||||
.addp .fields .name-rule { margin: 0; font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); color: var(--ghost) }
|
||||
.addp .fields .name-rule[data-bad] { color: var(--err) }
|
||||
.addp .fields .tip { font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); font-style: normal; color: var(--ghost) }
|
||||
.addp-section { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--hair) }
|
||||
.addp-section.compact { margin-top: 12px }
|
||||
@@ -3126,6 +3135,17 @@
|
||||
.me-list { display: flex; flex-direction: column; margin: 4px 0 0; padding: 0; list-style: none; border-top: 1px solid var(--hair) }
|
||||
.me-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px 10px; padding: 8px 0; border-bottom: 1px solid var(--hair) }
|
||||
.addp .fields .me-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
|
||||
.ml-group { display: flex; flex-direction: column; gap: 4px; margin-top: 14px }
|
||||
.ml-list { max-height: 232px; overflow-y: auto }
|
||||
.ml-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px }
|
||||
.ml-head strong { font: var(--w-semi) var(--fs-ui) var(--ui); color: var(--text) }
|
||||
.ml-head span { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
|
||||
.ml-row { display: grid; grid-template-columns: minmax(0, 1fr) 150px 150px; align-items: center; gap: 6px 10px; padding: 8px 0; border-bottom: 1px solid var(--hair) }
|
||||
.addp .ml-row .unit-field input { padding: 6px 8px; font-size: var(--fs-meta) }
|
||||
.ml-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
|
||||
.ml-row input::placeholder { color: var(--ghost) }
|
||||
.ml-row[data-own] .unit-field:has(input:not(:placeholder-shown)) { border-color: var(--accent) }
|
||||
@media (max-width: 640px) { .ml-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) } .ml-row .me-name { grid-column: 1 / -1 } }
|
||||
.me-row > .me-note, .me-own { grid-column: 1 / -1 }
|
||||
.me-own { display: flex; flex-direction: column; gap: 8px }
|
||||
.me-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px }
|
||||
@@ -3133,11 +3153,11 @@
|
||||
.addp .fields label.me-default { flex-direction: row; align-items: center; gap: 8px }
|
||||
.addp .more { margin-top: 14px }
|
||||
.addp .fields span { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
|
||||
.addp .fields input, .addp .fields select {
|
||||
.addp .fields input, .addp .fields select, .addp .ml-row input {
|
||||
padding: 6px 9px; border: 1px solid var(--border); border-radius: var(--r-xs);
|
||||
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--mono);
|
||||
}
|
||||
.addp .fields input:focus, .addp .fields select:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.addp .fields input:focus, .addp .fields select:focus, .addp .ml-row input:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.addp .acts { display: flex; gap: 8px; margin-top: 12px }
|
||||
.addp .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg) }
|
||||
.addp .act[data-primary]:disabled { border-color: var(--border); background: transparent; color: var(--ghost) }
|
||||
@@ -3190,6 +3210,7 @@
|
||||
box-shadow: var(--lit), 0 1px 2px rgb(0 0 0 / .07);
|
||||
}
|
||||
.mkt { display: flex; flex-direction: column; gap: 10px; min-width: 0 }
|
||||
.mkt[tabindex="-1"]:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
|
||||
.mkt.addpkg { border-top: 0; padding: 0 }
|
||||
.mkt-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px }
|
||||
.mkt-q {
|
||||
@@ -3260,6 +3281,8 @@
|
||||
}
|
||||
.mkt-fields .mono { font: var(--w-reg) var(--fs-meta)/1.5 var(--mono) }
|
||||
.mkt-fields textarea { resize: vertical }
|
||||
.mkt-pub .mkt-fields :is(input, textarea):disabled,
|
||||
.mkt-pub .mkt-seen:has(:disabled) { opacity: .5; cursor: default }
|
||||
.mkt-fields input:focus, .mkt-fields textarea:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.mkt-fields input::placeholder { color: var(--ghost) }
|
||||
.mkt-vote { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 0 }
|
||||
@@ -3339,6 +3362,7 @@
|
||||
padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--r-xs);
|
||||
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--mono);
|
||||
}
|
||||
.rule select { width: 200px; max-width: 100%; min-width: 0; text-overflow: ellipsis }
|
||||
.rule select:focus, .rule input:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.rule .match { flex: 1; min-width: 140px }
|
||||
.rule .cmd { display: block; width: 100%; margin-top: 8px }
|
||||
@@ -3363,7 +3387,7 @@
|
||||
.fromplugin { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--hair) }
|
||||
.fromplugin .lb { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
|
||||
.hookrow { display: flex; align-items: baseline; gap: 10px; margin-top: 6px }
|
||||
.hookrow .ev { flex: none; min-width: 128px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted) }
|
||||
.hookrow .ev { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted) }
|
||||
.hookrow .cm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
|
||||
.hookrow .sc { flex: none; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--ghost) }
|
||||
@@ -3614,7 +3638,8 @@
|
||||
}
|
||||
.memrow[data-used] .dot { background: var(--net); box-shadow: none }
|
||||
.memrow .nm {
|
||||
flex: none; min-width: 168px; padding: 0; border: 0; background: none; text-align: left;
|
||||
flex: 0 1 auto; min-width: 168px; max-width: 60%; padding: 0; border: 0; background: none; text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text); cursor: pointer;
|
||||
}
|
||||
.memrow .nm:hover { text-decoration: underline }
|
||||
@@ -3625,6 +3650,7 @@
|
||||
flex: none; padding: 1px 5px; border-radius: var(--r-pill); font: var(--w-med) var(--fs-micro) var(--ui); font-style: normal;
|
||||
color: var(--warn-ink); background: color-mix(in srgb, var(--warn) 14%, transparent);
|
||||
}
|
||||
.memrow .act { flex: none; white-space: nowrap }
|
||||
.memrow .sc { flex: none; min-width: 30px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
|
||||
.memrow .at { flex: none; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--ghost); font-variant-numeric: tabular-nums }
|
||||
.memrow .why-used { margin: -2px 0 8px 26px; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--net) }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.comm { display: flex; flex-direction: column; gap: 22px; }
|
||||
.comm-join { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 20px; }
|
||||
.comm-qr {
|
||||
flex: none; width: 185px; height: 185px; max-width: 100%; box-sizing: border-box;
|
||||
background: #fff; border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.comm-qq { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.comm-name { font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); color: var(--text); overflow-wrap: anywhere; }
|
||||
.comm-num { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.comm-num code { font: var(--w-med) var(--fs-lead) var(--mono); color: var(--text); letter-spacing: .02em; user-select: all; }
|
||||
.comm-k { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); }
|
||||
.comm-how { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); }
|
||||
.comm-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.comm-fail { margin: 0; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--err-ink); overflow-wrap: anywhere; }
|
||||
.comm-who { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding-top: 16px; border-top: 1px solid var(--hair); }
|
||||
.comm-who-hd { align-self: stretch; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
|
||||
.comm-who-hd h4 { margin: 0; font: var(--w-semi) var(--fs-ui) var(--ui); color: var(--text); }
|
||||
.comm-who-hd .btn { margin-left: auto; }
|
||||
.comm-list { align-self: stretch; margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.comm-list li { min-width: 0; }
|
||||
.comm-list a {
|
||||
display: block; max-width: 100%; padding: 2px 9px; border: 1px solid var(--hair); border-radius: var(--r-pill);
|
||||
font: var(--w-reg) var(--fs-meta)/1.7 var(--mono); color: var(--muted); text-decoration: none;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.comm-list a:hover { color: var(--text); border-color: var(--faint); }
|
||||
.comm-list a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.comm-dy { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
|
||||
.comm-dy code { font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted); user-select: all; }
|
||||
.comm-dy-qr { align-self: flex-start; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--r-md); }
|
||||
.comm-author { display: flex; align-items: baseline; gap: 10px; padding: 0 0 14px; }
|
||||
.comm-author a { font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text); text-decoration: none; }
|
||||
.comm-author a:hover { text-decoration: underline; }
|
||||
.comm-author a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
@@ -0,0 +1,238 @@
|
||||
.fbk-veil {
|
||||
position: fixed; inset: var(--chrome-h, 39px) 0 0; z-index: 55;
|
||||
display: grid; place-items: center; padding: 20px;
|
||||
background: color-mix(in srgb, var(--page) 55%, transparent);
|
||||
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
}
|
||||
.fbk {
|
||||
width: min(720px, 100%); max-height: 100%;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--r-lg);
|
||||
background: var(--float); box-shadow: var(--shadow-float);
|
||||
color: var(--text); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui);
|
||||
}
|
||||
.fbk-hd { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px 4px; }
|
||||
.fbk-hd h2 { margin: 0; font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
|
||||
.fbk-hd .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.fbk-hd .btn .studio-icon { width: 13px; height: 13px; }
|
||||
.fbk-hd .esc { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost); }
|
||||
.fbk-tabs { display: flex; gap: 2px; padding: 0 16px; border-bottom: 1px solid var(--hair); }
|
||||
.fbk-tabs button {
|
||||
position: relative; padding: 8px 12px; border: 0; background: transparent; color: var(--muted);
|
||||
font: var(--w-med) var(--fs-ui) var(--ui); cursor: pointer;
|
||||
}
|
||||
.fbk-tabs button:hover { color: var(--text); }
|
||||
.fbk-tabs button[aria-selected="true"] { color: var(--text); }
|
||||
.fbk-tabs button[aria-selected="true"]::after {
|
||||
content: ""; position: absolute; inset: auto 8px -1px; height: 2px; border-radius: 2px; background: var(--accent);
|
||||
}
|
||||
.fbk-tabs button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; border-radius: var(--r-xs); }
|
||||
.fbk-body { min-height: 0; overflow: auto; padding: 14px 16px 16px; }
|
||||
.fbk-body [hidden] { display: none; }
|
||||
|
||||
.fbk-form, .fbk-mine { display: flex; flex-direction: column; gap: 14px; }
|
||||
.fbk-form[data-over] .fbk-drop { border-color: var(--net); background: var(--net-wash); }
|
||||
|
||||
.fbk-notice {
|
||||
display: flex; gap: 10px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--border));
|
||||
border-left: 3px solid var(--warn); border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--warn) 9%, var(--raised));
|
||||
}
|
||||
.fbk-notice > .studio-icon { flex: none; width: 16px; height: 16px; margin-top: 2px; color: var(--warn-ink); }
|
||||
.fbk-notice h3 { margin: 0 0 3px; font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); }
|
||||
.fbk-notice p { margin: 0; color: var(--muted); font-size: var(--fs-meta); line-height: 1.6; }
|
||||
.fbk-notice p + p { margin-top: 4px; }
|
||||
.fbk-notice p[data-tone="warn"] { color: var(--warn-ink); font-weight: var(--w-med); }
|
||||
.fbk a { color: var(--net); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.fbk a:hover { color: var(--text); }
|
||||
.fbk a:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; }
|
||||
|
||||
.fbk-fields { display: flex; flex-direction: column; gap: 14px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.fbk-fields:disabled { opacity: .7; }
|
||||
.fbk-cats { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.fbk-cat { position: relative; }
|
||||
.fbk-cat input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
|
||||
.fbk-cat span {
|
||||
display: inline-block; padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-pill);
|
||||
color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui);
|
||||
}
|
||||
.fbk-cat:hover span { color: var(--text); border-color: var(--faint); }
|
||||
.fbk-cat input:checked + span { border-color: var(--accent); background: var(--accent-wash); color: var(--accent-ink); }
|
||||
.fbk-cat input:focus-visible + span { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
|
||||
.fbk-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
.fbk-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
||||
.fbk-label { font: var(--w-med) var(--fs-meta) var(--ui); color: var(--text); }
|
||||
.fbk-hint { font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--muted); }
|
||||
.fbk-hint[data-tone="warn"] { color: var(--warn-ink); }
|
||||
.fbk-field input:not([type="file"]), .fbk-field textarea {
|
||||
width: 100%; box-sizing: border-box; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--r-xs);
|
||||
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui);
|
||||
}
|
||||
.fbk-field textarea { resize: vertical; min-height: 110px; }
|
||||
.fbk-field input:focus, .fbk-field textarea:focus { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.fbk-field [aria-invalid="true"] { border-color: var(--err); }
|
||||
.fbk-count { align-self: flex-end; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--muted); }
|
||||
.fbk-count[data-over] { color: var(--err-ink); }
|
||||
|
||||
.fbk-drop {
|
||||
display: flex; flex-direction: column; gap: 10px; padding: 10px; border: 1px dashed var(--border);
|
||||
border-radius: var(--r-sm); background: var(--surface);
|
||||
}
|
||||
.fbk-drop-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
|
||||
.fbk-drop-line .btn { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.fbk-drop-line .btn .studio-icon { width: 12px; height: 12px; }
|
||||
.fbk-shots { display: flex; flex-wrap: wrap; gap: 10px; margin: 0; padding: 0; list-style: none; }
|
||||
.fbk-shots li {
|
||||
position: relative; width: 132px; display: flex; flex-direction: column; gap: 4px; padding: 6px;
|
||||
border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised);
|
||||
}
|
||||
.fbk-shots img { display: block; width: 100%; height: 84px; object-fit: cover; border-radius: var(--r-xs); background: var(--overlay); }
|
||||
.fbk-shot-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--muted); }
|
||||
.fbk-shot-x {
|
||||
position: absolute; top: 2px; right: 2px; width: 22px; height: 22px; display: grid; place-items: center; padding: 0;
|
||||
border: 1px solid var(--border); border-radius: 50%; background: var(--float); color: var(--text); cursor: pointer;
|
||||
}
|
||||
.fbk-shot-x .studio-icon { width: 11px; height: 11px; }
|
||||
.fbk-shot-x:hover { border-color: var(--err); color: var(--err-ink); }
|
||||
.fbk-shot-x:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.fbk-refused { margin: 0; padding: 0; list-style: none; color: var(--err-ink); font-size: var(--fs-meta); }
|
||||
|
||||
.fbk-env { padding: 10px 12px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--surface); }
|
||||
.fbk-env h3 { margin: 0 0 6px; font: var(--w-med) var(--fs-meta) var(--ui); color: var(--text); }
|
||||
.fbk-env dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 14px; margin: 0 0 6px; }
|
||||
.fbk-env dt { color: var(--muted); font-size: var(--fs-meta); }
|
||||
.fbk-env dd { margin: 0; overflow-wrap: anywhere; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--text); }
|
||||
.fbk-env .fbk-hint { margin: 0; }
|
||||
|
||||
.fbk-note {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; padding: 8px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm); background: var(--surface); color: var(--text); font-size: var(--fs-ui);
|
||||
}
|
||||
.fbk-note > span { flex: 1 1 200px; min-width: 0; }
|
||||
.fbk-note > .studio-icon { flex: none; width: 15px; height: 15px; }
|
||||
.fbk-note[data-tone="error"] { border-color: color-mix(in srgb, var(--err) 50%, var(--border)); background: color-mix(in srgb, var(--err) 8%, var(--raised)); }
|
||||
.fbk-note[data-tone="error"] > .studio-icon { color: var(--err-ink); }
|
||||
.fbk-note[data-tone="warn"] { border-color: color-mix(in srgb, var(--warn) 45%, var(--border)); background: color-mix(in srgb, var(--warn) 8%, var(--raised)); }
|
||||
.fbk-note[data-tone="warn"] > .studio-icon { color: var(--warn-ink); }
|
||||
.fbk-note[data-tone="info"] > .studio-icon { color: var(--net); }
|
||||
|
||||
.fbk-foot {
|
||||
position: sticky; bottom: -16px; z-index: 1; display: flex; flex-direction: column; gap: 10px;
|
||||
margin: 0 -16px -16px; padding: 10px 16px 14px; border-top: 1px solid var(--hair); background: var(--float);
|
||||
}
|
||||
.fbk-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.fbk-acts .act { padding: 6px 14px; font: var(--w-med) var(--fs-ui) var(--ui); }
|
||||
.fbk-acts .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.fbk-acts .act[data-primary]:hover:not(:disabled) { background: var(--accent); filter: brightness(1.08); }
|
||||
.fbk-acts .act[data-primary]:disabled { border-color: var(--border); background: transparent; color: var(--ghost); }
|
||||
|
||||
.fbk-sent { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; padding: 8px 0; }
|
||||
.fbk-sent > .studio-icon { width: 30px; height: 30px; padding: 6px; border-radius: 50%; background: color-mix(in srgb, var(--ok) 14%, var(--raised)); color: var(--ok-ink); }
|
||||
.fbk-sent h3 { margin: 0; font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); }
|
||||
.fbk-sent h3:focus { outline: none; }
|
||||
.fbk-receipt { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.fbk-receipt code {
|
||||
padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface);
|
||||
font: var(--w-med) var(--fs-title) var(--mono); letter-spacing: .04em; user-select: all;
|
||||
}
|
||||
|
||||
.fbk-mine-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.fbk-mine-bar .btn { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.fbk-mine-bar .btn .studio-icon { width: 12px; height: 12px; }
|
||||
.fbk-empty { margin: 8px 0; padding: 22px 12px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: var(--r-sm); }
|
||||
.fbk-list { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--hair); }
|
||||
.fbk-item { display: flex; flex-direction: column; gap: 6px; padding: 12px 0; border-bottom: 1px solid var(--hair); }
|
||||
.fbk-item-hd { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
|
||||
.fbk-code { font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text); }
|
||||
.fbk-item-hd .copy { padding: 2px; }
|
||||
.fbk-receipt .copy {
|
||||
padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--r-pill); background: transparent;
|
||||
color: var(--text); font: var(--w-med) var(--fs-meta) var(--ui); cursor: pointer;
|
||||
}
|
||||
.fbk-receipt .copy:hover { background: var(--overlay); }
|
||||
.fbk-meta { flex: 1 1 auto; color: var(--muted); font-size: var(--fs-meta); }
|
||||
.fbk-snippet { margin: 0; overflow-wrap: anywhere; color: var(--text); }
|
||||
.fbk-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill); background: var(--surface); color: var(--text); font: var(--w-med) var(--fs-meta) var(--ui);
|
||||
}
|
||||
.fbk-chip .studio-icon { width: 11px; height: 11px; }
|
||||
.fbk-chip[data-status="received"] .studio-icon { color: var(--muted); }
|
||||
.fbk-chip[data-status="recorded"] { border-color: color-mix(in srgb, var(--net) 40%, var(--border)); }
|
||||
.fbk-chip[data-status="recorded"] .studio-icon { color: var(--net); }
|
||||
.fbk-chip[data-status="in_progress"] { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); background: var(--accent-wash); color: var(--accent-ink); }
|
||||
.fbk-chip[data-status="fixed"] { border-color: color-mix(in srgb, var(--ok) 45%, var(--border)); background: color-mix(in srgb, var(--ok) 10%, var(--raised)); }
|
||||
.fbk-chip[data-status="fixed"] .studio-icon { color: var(--ok-ink); }
|
||||
.fbk-chip[data-status="wontfix"] .studio-icon, .fbk-chip[data-status="duplicate"] .studio-icon { color: var(--muted); }
|
||||
.fbk-chip[data-status="needs_info"] { border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); background: color-mix(in srgb, var(--warn) 11%, var(--raised)); }
|
||||
.fbk-chip[data-status="needs_info"] .studio-icon { color: var(--warn-ink); }
|
||||
.fbk-chip[data-status="answered"] { border-color: color-mix(in srgb, var(--net) 45%, var(--border)); }
|
||||
.fbk-chip[data-status="answered"] .studio-icon { color: var(--net); }
|
||||
.fbk-chip[data-status="closed"] { color: var(--muted); background: transparent; }
|
||||
.fbk-chip[data-status="closed"] .studio-icon { color: var(--muted); }
|
||||
|
||||
.fbk-tl { display: flex; flex-wrap: wrap; gap: 4px 0; margin: 0; padding: 0; list-style: none; }
|
||||
.fbk-tl li { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--fs-meta); }
|
||||
.fbk-note .btn { color: var(--text); }
|
||||
.fbk-tl li:not(:last-child)::after { content: ""; width: 18px; height: 1px; margin: 0 6px; background: var(--border); }
|
||||
.fbk-tl i { flex: none; width: 9px; height: 9px; border: 1.5px solid var(--ghost); border-radius: 50%; }
|
||||
.fbk-tl li[data-state="done"] { color: var(--text); }
|
||||
.fbk-tl li[data-state="done"] i { border-color: var(--ok); background: var(--ok); }
|
||||
.fbk-tl li[data-state="current"] { color: var(--text); font-weight: var(--w-med); }
|
||||
.fbk-tl li[data-state="current"] i { border-color: var(--accent); background: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); }
|
||||
.fbk-tl li[data-state="todo"] { color: var(--ghost); }
|
||||
|
||||
.studio-feedback { margin-top: 0; }
|
||||
.studio-feedback > span { color: var(--muted); }
|
||||
.fbk-item[data-stale] .fbk-snippet, .fbk-item[data-stale] .fbk-meta { color: var(--muted); }
|
||||
.fbk-chip[data-status="unavailable"] { border-style: dashed; color: var(--muted); background: transparent; }
|
||||
@media (max-width: 800px) {
|
||||
.fbk-tl { flex-direction: column; align-items: flex-start; }
|
||||
.fbk-tl li:not(:last-child)::after { display: none; }
|
||||
}
|
||||
|
||||
.fbk-thread { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.fbk-thread ol { display: flex; flex-direction: column; gap: 8px; width: 100%; margin: 0; padding: 0; list-style: none; }
|
||||
.fbk-thread li {
|
||||
display: flex; flex-direction: column; gap: 3px; max-width: min(92%, 560px); padding: 8px 11px;
|
||||
border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--surface);
|
||||
}
|
||||
.fbk-thread li[data-author="user"] {
|
||||
align-self: flex-end; border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); background: var(--accent-wash);
|
||||
}
|
||||
.fbk-thread li[data-new] { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
|
||||
.fbk-msg-hd { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--fs-meta); }
|
||||
.fbk-msg-hd b { color: var(--text); font-weight: var(--w-med); }
|
||||
.fbk-msg-hd time { margin-left: auto; }
|
||||
.fbk-new {
|
||||
padding: 0 7px; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg);
|
||||
font: var(--w-med) var(--fs-micro)/1.6 var(--ui);
|
||||
}
|
||||
.fbk-msg { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; color: var(--text); }
|
||||
.fbk-thread .btn { display: inline-flex; align-items: center; }
|
||||
|
||||
.fbk-reply-open { align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.fbk-reply-open .studio-icon { width: 12px; height: 12px; }
|
||||
.fbk-reply {
|
||||
display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm); background: var(--surface);
|
||||
}
|
||||
.fbk-reply[data-needs] { border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); }
|
||||
.fbk-reply textarea {
|
||||
width: 100%; box-sizing: border-box; min-height: 72px; padding: 7px 10px; border: 1px solid var(--border);
|
||||
border-radius: var(--r-xs); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); resize: vertical;
|
||||
}
|
||||
.fbk-reply textarea:focus { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.fbk-reply textarea[aria-invalid="true"] { border-color: var(--err); }
|
||||
.fbk-reply-foot { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 4px 12px; }
|
||||
.fbk-reply-foot .fbk-hint { flex: 1 1 240px; }
|
||||
.fbk-item:focus { outline: none; }
|
||||
|
||||
.studio-feedback .fbk-badge {
|
||||
margin-left: auto; min-width: 16px; height: 16px; padding: 0 5px; box-sizing: border-box; display: inline-grid; place-items: center;
|
||||
border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) 10px/1 var(--ui);
|
||||
}
|
||||
.fbk-msg { unicode-bidi: isolate; }
|
||||
.fbk-tl li[data-tone="neutral"] i { border-color: var(--ghost); background: var(--ghost); }
|
||||
.palette-hit > small[data-alert] { color: var(--text); font: 600 11px/1 var(--ui); }
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CSS = import.meta.glob("./*.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
|
||||
const MIN_LINE_HEIGHT = 1.2;
|
||||
|
||||
interface Rule {
|
||||
file: string;
|
||||
selector: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function rules(): Rule[] {
|
||||
const out: Rule[] = [];
|
||||
for (const [file, css] of Object.entries(CSS)) {
|
||||
for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) out.push({ file, selector: m[1].trim(), body: m[2] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lineHeight(body: string): number | undefined {
|
||||
const longhand = body.match(/(?:^|[;\s])line-height\s*:\s*([\d.]+)\s*(?:;|$)/);
|
||||
if (longhand) return Number(longhand[1]);
|
||||
const shorthand = body.match(/(?:^|[;\s])font\s*:[^;]*?\/\s*([\d.]+)(?=\s|;|$)/);
|
||||
return shorthand ? Number(shorthand[1]) : undefined;
|
||||
}
|
||||
|
||||
const clips = (body: string) => /overflow(?:-x)?\s*:\s*hidden|text-overflow\s*:/.test(body);
|
||||
|
||||
describe("a clipped single line keeps room for the glyphs of any UI font", () => {
|
||||
it("never pairs overflow clipping with a unitless line-height below 1.2", () => {
|
||||
const tight = rules()
|
||||
.filter((r) => clips(r.body))
|
||||
.filter((r) => (lineHeight(r.body) ?? Infinity) < MIN_LINE_HEIGHT)
|
||||
.map((r) => `${r.file} ${r.selector}`);
|
||||
expect(tight).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the composer's model name its own line-height, since it inherits a tight one from the picker", () => {
|
||||
const names = rules().filter((r) => /\.model-picker \.nm$/.test(r.selector) && clips(r.body));
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
const bare = names.filter((r) => (lineHeight(r.body) ?? 0) < MIN_LINE_HEIGHT).map((r) => `${r.file} ${r.selector}`);
|
||||
expect(bare).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CSS = Object.values(
|
||||
import.meta.glob("./app.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>,
|
||||
)[0];
|
||||
|
||||
function declarations(selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const m = CSS.match(new RegExp(String.raw`(?:^|\n)\s*${escaped}\s*\{([^}]*)\}`));
|
||||
return m?.[1] ?? "";
|
||||
}
|
||||
|
||||
describe("a memory row stays inside its container", () => {
|
||||
const name = declarations(".memrow .nm");
|
||||
|
||||
it("lets the title shrink and wrap instead of pushing the row wider", () => {
|
||||
expect(name, "no .memrow .nm rule found").not.toBe("");
|
||||
expect(name).not.toMatch(/flex:\s*none/);
|
||||
expect(name).toMatch(/overflow-wrap:\s*anywhere/);
|
||||
expect(name).toMatch(/max-width:/);
|
||||
});
|
||||
|
||||
it("keeps the trailing controls whole", () => {
|
||||
expect(declarations(".memrow .act")).toMatch(/white-space:\s*nowrap/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SHEET = Object.values(
|
||||
import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>,
|
||||
)[0];
|
||||
|
||||
interface Rule {
|
||||
selector: string;
|
||||
decls: Map<string, string>;
|
||||
}
|
||||
|
||||
function rules(css: string): Rule[] {
|
||||
const out: Rule[] = [];
|
||||
for (const m of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const decls = new Map<string, string>();
|
||||
for (const part of m[2].split(";")) {
|
||||
const at = part.indexOf(":");
|
||||
if (at > 0) decls.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
|
||||
}
|
||||
out.push({ selector: m[1].trim(), decls });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("status rail figures hold their width", () => {
|
||||
const rail = rules(SHEET).filter((r) => r.selector.split(",").some((s) => s.includes(".studio-meterrail")));
|
||||
|
||||
it("finds the rail rules", () => {
|
||||
expect(rail.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps tabular numerals on every rule that sets the font shorthand", () => {
|
||||
const reset = rail
|
||||
.filter((r) => r.decls.has("font") && r.decls.get("font-variant-numeric") !== "tabular-nums")
|
||||
.map((r) => r.selector.replace(/\s+/g, " "));
|
||||
expect(reset, "the font shorthand resets font-variant-numeric; restate tabular-nums in the same rule").toEqual([]);
|
||||
});
|
||||
|
||||
it("reserves the width of a three-digit speed so 9.9 to 10.0 and 99.9 to 100.0 do not move its neighbours", () => {
|
||||
const speed = rail.find((r) => r.selector === ".studio-meterrail .studio-meter-speed b");
|
||||
expect(speed?.decls.get("min-width")).toBe("5ch");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
const css = Object.values(CSS)[0];
|
||||
const stage = [...css.matchAll(/\.onb-stage\s*\{([^}]*)\}/g)].map((match) => match[1]).join("\n");
|
||||
|
||||
describe("onboarding folds in the room left after interface zoom", () => {
|
||||
it("measures its own inline size", () => {
|
||||
expect(stage).toMatch(/container:\s*onboarding\s*\/\s*inline-size/);
|
||||
});
|
||||
|
||||
it("moves the complete card to one column through a container query", () => {
|
||||
const fold = css.match(/@container onboarding \(max-width: 760px\)\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||
expect(fold).toMatch(/\.onb-shell\s*\{[^}]*grid-template-columns:\s*1fr/);
|
||||
expect(fold).toContain(".onb-progress");
|
||||
expect(fold).toContain(".onb { padding: 26px 22px 24px; }");
|
||||
const media = css.match(/@media \(max-width: 760px\)\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||
expect(media).not.toContain(".onb");
|
||||
});
|
||||
|
||||
it("keeps viewport scrolling and focus clearance independent of the fold", () => {
|
||||
expect(stage).toContain("height: calc(100dvh / var(--zoom, 1))");
|
||||
expect(stage).toContain("scroll-padding-top: 64px");
|
||||
expect(stage).toContain("overflow: auto");
|
||||
expect(css).toMatch(/\.onb-brandbar\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0/);
|
||||
});
|
||||
});
|
||||
@@ -326,7 +326,9 @@ body { background: var(--page); }
|
||||
|
||||
/* First-run connection */
|
||||
.onb-stage {
|
||||
min-height: 100vh;
|
||||
height: calc(100dvh / var(--zoom, 1));
|
||||
scroll-padding-top: 64px;
|
||||
container: onboarding / inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
@@ -336,6 +338,7 @@ body { background: var(--page); }
|
||||
}
|
||||
.onb-brandbar {
|
||||
height: 64px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
@@ -358,9 +361,10 @@ body { background: var(--page); }
|
||||
.onb-shell {
|
||||
width: min(920px, calc(100% - 48px));
|
||||
margin: auto;
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: 230px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
@@ -851,13 +855,35 @@ body { background: var(--page) !important; }
|
||||
.acts-pop > button:hover { background: var(--overlay); color: var(--text); }
|
||||
.flow .call[data-k="say"] .copy[data-icon][data-state="done"] { color: var(--ghost); }
|
||||
.flow .call[data-k="me"] {
|
||||
display: flex; justify-content: flex-end; margin: 8px 0 28px;
|
||||
display: flex; justify-content: flex-end; margin: 8px 0 0;
|
||||
background: transparent; border: 0; box-shadow: none;
|
||||
}
|
||||
.flow .call[data-k="me"]:hover { background: transparent; border-color: transparent; }
|
||||
.flow .call[data-k="me"]::before { display: none; }
|
||||
.flow .call[data-k="me"] > .c { width: auto; max-width: 70%; padding: 0; }
|
||||
.flow .call[data-k="me"] .user-hl { min-height: 0; margin: 0 3px 4px; justify-content: flex-end; flex-wrap: nowrap; contain: inline-size; }
|
||||
.flow .user-acts { display: flex; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 4px; min-height: 26px; contain: inline-size; opacity: 0; transition: opacity var(--m-line); }
|
||||
.flow .call > .c:hover .user-acts,
|
||||
.flow .call > .c:focus-within .user-acts,
|
||||
.flow .user-acts:has(.rewind[data-open]),
|
||||
.flow .user-acts:has(.copy[data-feedback]) { opacity: 1; }
|
||||
.flow .user-acts > .rewind { margin-left: 0; opacity: 1; }
|
||||
.flow .user-acts .rewind > .picker { display: flex; }
|
||||
.flow .user-acts > button,
|
||||
.flow .user-acts .stepctl button {
|
||||
display: inline-flex; align-items: center; justify-content: center; flex: none;
|
||||
width: 26px; height: 26px; padding: 0; opacity: 1; line-height: 12px;
|
||||
border: 0; border-radius: 6px; background: transparent; color: var(--muted);
|
||||
}
|
||||
.flow .user-acts .studio-icon { width: 12px; height: 12px; }
|
||||
.flow .call[data-k="me"] .user-acts > button:hover,
|
||||
.flow .call[data-k="me"] .user-acts .stepctl button:hover { background: var(--overlay); color: var(--text); }
|
||||
.flow .user-acts > .copy[data-feedback] { width: auto; gap: 5px; padding: 0 6px; white-space: nowrap; }
|
||||
.flow .user-acts > button.copy[data-state="done"],
|
||||
.flow .user-acts > button.copy[data-state="done"]:hover { color: var(--ok); }
|
||||
.flow .user-acts > button.copy[data-state="failed"],
|
||||
.flow .user-acts > button.copy[data-state="failed"]:hover { color: var(--warn-ink); }
|
||||
.flow .call[data-k="me"] .user-acts > .reask-open { background: transparent; color: var(--muted); }
|
||||
.flow .call[data-k="me"] .user-hl > .nm { display: none; }
|
||||
.flow .call[data-k="me"] .out { margin-top: 0; }
|
||||
.flow .call[data-k="me"] .out .txt {
|
||||
@@ -1126,7 +1152,7 @@ body { background: var(--page) !important; }
|
||||
.turntools .studio-branch { max-width: 160px; }
|
||||
.turntools .studio-branch .lb { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.turntools .model-picker { max-width: 180px; }
|
||||
.turntools .model-picker .nm { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.turntools .model-picker .nm { max-width: 130px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.compose .mode {
|
||||
min-height: 28px;
|
||||
border-color: transparent;
|
||||
@@ -1176,7 +1202,7 @@ body { background: var(--page) !important; }
|
||||
.studio-meterrail > .studio-meter-cost {
|
||||
height: 25px; display: inline-flex; align-items: center; gap: 5px; padding: 0 6px;
|
||||
border: 0; border-radius: 5px; background: transparent; color: var(--faint);
|
||||
white-space: nowrap; font: 500 10px/1 var(--ui);
|
||||
white-space: nowrap; font: 500 10px/1 var(--ui); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.studio-meterrail > button,
|
||||
.studio-meterrail .studio-context-anchor > button,
|
||||
@@ -1187,7 +1213,8 @@ body { background: var(--page) !important; }
|
||||
background: color-mix(in srgb, var(--text) 7%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
.studio-meterrail b { color: inherit; font: 500 10px/1 var(--ui); }
|
||||
.studio-meterrail b { color: inherit; font: 500 10px/1 var(--ui); font-variant-numeric: tabular-nums; }
|
||||
.studio-meterrail .studio-meter-speed b { min-width: 5ch; text-align: right; }
|
||||
.studio-meterrail .studio-icon { width: 13px; height: 13px; }
|
||||
/* The line reads before the figure: the shape answers "is it moving" at a
|
||||
glance, and the number is what you look at once it has. It takes no room
|
||||
@@ -1406,7 +1433,7 @@ body { background: var(--page) !important; }
|
||||
.workbench-filelist em { color: var(--warn); font-style: normal; }
|
||||
.workbench-no-files { margin: 18px 12px; color: var(--faint); font: 500 10px/1.4 var(--ui); text-align: center; }
|
||||
.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); }
|
||||
.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1 var(--mono); }
|
||||
.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1.5 var(--mono); }
|
||||
.workbench-filebar [role="group"] { display: flex; padding: 2px; background: var(--page); border: 1px solid var(--hair); border-radius: 5px; }
|
||||
.workbench-filebar button { min-height: 25px; padding: 0 8px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font: 600 10px/1 var(--ui); }
|
||||
.workbench-filebar button[aria-pressed="true"] { background: var(--raised); color: var(--text); }
|
||||
@@ -1650,11 +1677,16 @@ body { background: var(--page) !important; }
|
||||
.onb-model-menu .mi:hover,
|
||||
.onb-model-menu .mi[data-lead] { background: var(--overlay); }
|
||||
.onb-model-menu .mfind { background: var(--float); color: var(--text); border-bottom-color: var(--border); }
|
||||
.onb-foot { padding: 14px 24px 18px; text-align: center; color: var(--ghost); font: 500 10px/1 var(--ui); }
|
||||
.onb-foot { flex: none; padding: 14px 24px 18px; text-align: center; color: var(--ghost); font: 500 10px/1 var(--ui); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app { --nav-w: 44px; }
|
||||
.studio-brand > span:nth-child(2) small { display: none; }
|
||||
.flow { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
|
||||
.compose { width: calc(100% - 20px); margin-inline: auto; }
|
||||
}
|
||||
|
||||
@container onboarding (max-width: 760px) {
|
||||
.onb-shell { width: min(560px, calc(100% - 24px)); grid-template-columns: 1fr; }
|
||||
.onb-progress { padding: 18px 20px; border-right: 0; border-bottom: 1px solid var(--hair); }
|
||||
.onb-progress ol { margin-top: 14px; display: flex; gap: 10px; }
|
||||
@@ -1662,8 +1694,6 @@ body { background: var(--page) !important; }
|
||||
.onb-progress li span small,
|
||||
.onb-progress p { display: none; }
|
||||
.onb { padding: 26px 22px 24px; }
|
||||
.flow { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
|
||||
.compose { width: calc(100% - 20px); margin-inline: auto; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1738,7 +1768,7 @@ body { background: var(--page) !important; }
|
||||
|
||||
.onb-stage::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
top: 7%;
|
||||
right: 10%;
|
||||
@@ -1755,7 +1785,8 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
|
||||
.onb-brandbar {
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: color-mix(in srgb, var(--page) 76%, transparent);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.12);
|
||||
@@ -1864,6 +1895,8 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
display: flex; align-items: center; overflow: hidden;
|
||||
font: 400 11px/1.45 var(--ui); letter-spacing: -.01em;
|
||||
}
|
||||
.rail .sessrow-context > .sesstitle { padding-inline-end: 9px; }
|
||||
.rail .sessrow-context > .sesscopies { margin-inline-end: 8px; }
|
||||
.rail .sesstitle > span {
|
||||
min-width: 0; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
|
||||
overflow: hidden; white-space: normal; overflow-wrap: anywhere; word-break: break-word;
|
||||
@@ -2145,7 +2178,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
}
|
||||
.studio-user-foot .acctrow { width: 30px; min-width: 30px; height: 30px; margin: 0; padding: 0; overflow: hidden; }
|
||||
.studio-user-foot .ini { width: 28px; height: 28px; }
|
||||
.studio-workspace-kind { color: var(--faint); font: 500 11px/1 var(--ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-workspace-kind { color: var(--faint); font: 500 11px/1.5 var(--ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.turntools > .attach { order: 1 !important; }
|
||||
.turntools > .studio-mode-control { order: 2 !important; }
|
||||
@@ -2184,7 +2217,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.studio-mode-picker .studio-icon:last-child,
|
||||
.studio-effort-picker .studio-icon:last-child,
|
||||
.model-picker .studio-icon:last-child { width: 11px; height: 11px; color: var(--ghost); }
|
||||
.studio-model-control .model-picker .nm { max-width: 125px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-model-control .model-picker .nm { max-width: 125px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-model-control .model-picker {
|
||||
min-height: 28px;
|
||||
justify-content: flex-start;
|
||||
@@ -3605,6 +3638,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.grp .now,
|
||||
.vway .lb,
|
||||
.addp .fields > label > span,
|
||||
.addp .fields .name-cell > label > span,
|
||||
.addp-section-head span { font-size: var(--prefs-meta); }
|
||||
.addp-head strong,
|
||||
.addp-section-head strong,
|
||||
@@ -3998,6 +4032,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
:root[data-theme="light"] .refine-card { background: var(--page); }
|
||||
@media (hover: none) {
|
||||
.flow .user-acts,
|
||||
.wsdel,
|
||||
.rail .sessrow > .wsdel,
|
||||
.rail .session-more,
|
||||
@@ -4069,3 +4104,38 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.studio-effort-menu .mi[aria-checked] .studio-item-badge { background: var(--overlay); color: var(--warn-ink); }
|
||||
.studio-effort-menu .mi[aria-checked] { position: sticky; bottom: 0; z-index: 1; background: var(--raised); box-shadow: 0 -1px 0 var(--border); }
|
||||
.studio-effort-menu .mi[aria-checked]:not(:disabled):hover { background: var(--overlay); }
|
||||
.bound .out-more { margin: 0 0 0 8px; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
|
||||
.bound .out-more:hover { background: var(--overlay); color: var(--text); }
|
||||
.flow .bound .out-more { padding: 0 4px; }
|
||||
.ag-open {
|
||||
flex: none; width: 22px; height: 22px; display: grid; place-items: center; margin: -3px -4px -3px 0;
|
||||
padding: 0; border: 0; border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.ag-open .studio-icon { width: 11px; height: 11px; }
|
||||
.ag-open:hover { background: var(--overlay); color: var(--text); }
|
||||
.ag-open:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
|
||||
@media (pointer: coarse) { .ag-open { width: 32px; height: 32px; margin: -8px -8px -8px 0; } }
|
||||
.agent-tx-scrim {
|
||||
position: fixed; inset: var(--chrome-h, 39px) 0 0; z-index: 60; display: grid; place-items: center; padding: 28px;
|
||||
background: color-mix(in srgb, var(--page) 40%, transparent);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(.92); backdrop-filter: blur(14px) saturate(.92);
|
||||
}
|
||||
.agent-tx {
|
||||
width: min(720px, 100%); max-height: 100%; display: flex; flex-direction: column; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float);
|
||||
}
|
||||
.agent-tx-hd { display: flex; align-items: center; gap: 9px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--hair); }
|
||||
.agent-tx-hd .who { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--deleg); }
|
||||
.agent-tx-hd .rt { margin-left: auto; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint); }
|
||||
.agent-tx-x {
|
||||
flex: none; width: 26px; height: 26px; display: grid; place-items: center; padding: 0; border: 0;
|
||||
border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.agent-tx-x .studio-icon { width: 12px; height: 12px; }
|
||||
.agent-tx-x:hover { background: var(--overlay); color: var(--text); }
|
||||
.agent-tx-x:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
|
||||
.agent-tx-bd { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 14px 10px; }
|
||||
.agent-tx .nest-ret { flex: none; max-height: 30vh; overflow-y: auto; margin: 4px 14px 14px; white-space: pre-wrap; }
|
||||
.nest-bd :is(.term, .term-l) { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.agent-tx .call { content-visibility: auto; contain-intrinsic-size: auto 80px; }
|
||||
.agent-tx:focus { outline: none; }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { AuthorLine } from "./AuthorLine";
|
||||
import { Community } from "./Community";
|
||||
import { Group } from "./Group";
|
||||
import { Versions } from "./Versions";
|
||||
|
||||
export function About({ port }: { port: AgentPort }) {
|
||||
return (
|
||||
<>
|
||||
<Group id="versions"
|
||||
title={t("版本")}
|
||||
hint={t("当前安装的版本、可用更新,以及出现问题时如何回退。更新下载好后由你决定何时重启;回退后会固定在所选版本,不再提示新版本。")}
|
||||
>
|
||||
<Versions port={port} />
|
||||
</Group>
|
||||
<AuthorLine port={port} />
|
||||
<Group id="community"
|
||||
title={t("社区与贡献者")}
|
||||
hint={t("遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。")}
|
||||
>
|
||||
<Community port={port} />
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEscape } from "./dismiss";
|
||||
import { t } from "../i18n";
|
||||
import { current as language, plural, t } from "../i18n";
|
||||
import { useFileDrop } from "./filedrop";
|
||||
import type { AgentPort, PluginAction, PluginPackage, PluginPlan } from "../port/port";
|
||||
import { reason } from "../i18n/kernel";
|
||||
@@ -29,16 +29,20 @@ interface Props {
|
||||
updating?: PluginPackage;
|
||||
// A source someone already chose, such as a plugin named in a restored backup.
|
||||
source?: string;
|
||||
onApplying?: (applying: boolean) => void;
|
||||
}
|
||||
|
||||
export function AddPlugin({ port, onClose, onInstalled, updating, source }: Props) {
|
||||
// Mounted only while open, so this component is the layer Escape closes.
|
||||
useEscape(true, onClose);
|
||||
export function AddPlugin({ port, onClose, onInstalled, updating, source, onApplying }: Props) {
|
||||
const [text, setText] = useState(updating?.source ?? source ?? "");
|
||||
const [plan, setPlan] = useState<PluginPlan | null>(null);
|
||||
const [done, setDone] = useState<PluginPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const apply = useRef<HTMLButtonElement>(null);
|
||||
useEscape(true, () => { if (!updating || !plan || !busy) onClose(); });
|
||||
useEffect(() => {
|
||||
if (updating?.name && !busy && (done || (error && plan))) apply.current?.focus();
|
||||
}, [updating?.name, error, plan, done, busy]);
|
||||
|
||||
const request = (planId?: string) => ({
|
||||
source: text.trim(),
|
||||
@@ -48,6 +52,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
});
|
||||
|
||||
const look = async () => {
|
||||
if (busy || !text.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -71,6 +76,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
const install = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
onApplying?.(true);
|
||||
setError("");
|
||||
try {
|
||||
const out = await port.installPlugin(request(plan.planId));
|
||||
@@ -80,12 +86,22 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
onApplying?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pick = async () => {
|
||||
const dir = await port.pickFolder();
|
||||
if (dir) setText(dir);
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const dir = await port.pickFolder();
|
||||
if (dir) setText(dir);
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// An update has nothing to type: its source was recorded at install. Going
|
||||
@@ -100,14 +116,14 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
// you agree to install it. A tab has no path to fill it with, so nothing is
|
||||
// typed there and the box still takes an address.
|
||||
const [over, setOver] = useState(false);
|
||||
const drop = useFileDrop((d) => d.paths[0] && setText(d.paths[0]), setOver);
|
||||
const drop = useFileDrop((d) => !busy && d.paths[0] && setText(d.paths[0]), setOver);
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="addpkg" data-stage="done">
|
||||
<Outcome plan={done} />
|
||||
<div className="acts">
|
||||
<button className="act" data-action="extensions.finish" onClick={onClose}>
|
||||
<button className="act" data-action="extensions.finish" ref={apply} onClick={onClose}>
|
||||
{t("完成")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -122,7 +138,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
})).filter((g) => g.actions.length > 0);
|
||||
const gained = updating ? newlyGained(updating, plan.actions ?? []) : [];
|
||||
return (
|
||||
<div className="addpkg" data-stage="confirm">
|
||||
<div className="addpkg" data-stage="confirm" aria-busy={busy}>
|
||||
{/* What this version brings that the installed one did not. A package
|
||||
that was only skills last time and starts a process this time is
|
||||
the case the whole confirmation exists for. */}
|
||||
@@ -133,12 +149,12 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
? `${updating.name} ${updating.version} → ${version(plan.actions)}`
|
||||
: t("重装 {name}", { name: updating.name })}
|
||||
</span>
|
||||
<span className="why">{gained.length ? t("本版本新增:{list}", { list: gained.join("、") }) : t("没有新增的可执行内容。")}</span>
|
||||
<span className="why">{gained.length ? t("本版本新增:{list}", { list: gained.join(language() === "zh" ? "、" : " · ") }) : t("没有新增的可执行内容。")}</span>
|
||||
</div>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<section className="rgrp" key={g.level} data-lvl={g.level}>
|
||||
<h3>{RISK_TITLE[g.level] ?? g.level}</h3>
|
||||
<h3>{t(RISK_TITLE[g.level] ?? g.level)}</h3>
|
||||
{g.actions.map((a, i) => (
|
||||
<Candidate a={a} key={`${a.kind}:${a.name}:${i}`} />
|
||||
))}
|
||||
@@ -154,6 +170,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
<button
|
||||
className="act"
|
||||
data-action={updating ? "extensions.cancel" : "extensions.back"}
|
||||
disabled={busy}
|
||||
onClick={() => (updating ? onClose() : setPlan(null))}
|
||||
>
|
||||
{t(updating ? "取消" : "返回")}
|
||||
@@ -161,6 +178,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
<button
|
||||
className="act"
|
||||
data-action={updating ? "extensions.update" : "extensions.install"}
|
||||
ref={apply}
|
||||
data-primary
|
||||
disabled={busy}
|
||||
onClick={() => void install()}
|
||||
@@ -168,7 +186,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
{t(busy ? (updating ? "更新中…" : "安装中…") : updating ? "更新" : "安装")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
{error && <div className="why" role="alert">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -177,7 +195,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
// a failure there is about the recorded source, not about what was typed.
|
||||
if (updating) {
|
||||
return (
|
||||
<div className="addpkg" data-stage="reading">
|
||||
<div className="addpkg" data-stage="reading" aria-busy={busy}>
|
||||
<div className="find" data-lvl={error ? "err" : undefined}>
|
||||
<span className="t">{error ? t("无法读取 {name} 的来源", { name: updating.name }) : t("正在读取 {name} 的来源…", { name: updating.name })}</span>
|
||||
<span className="why">{error || updating.source}</span>
|
||||
@@ -192,22 +210,26 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="addpkg" data-stage="paste" ref={drop} data-over={over ? "" : undefined}>
|
||||
<div className="addpkg" data-stage="paste" ref={drop} data-over={over ? "" : undefined} aria-busy={busy}>
|
||||
<textarea
|
||||
className="paste"
|
||||
data-action-keydown="extensions.inspect"
|
||||
rows={3}
|
||||
autoFocus
|
||||
value={text}
|
||||
disabled={busy}
|
||||
placeholder={PLACEHOLDER}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void look();
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void look();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="acts">
|
||||
<span className="note">{t("仓库地址,或将文件夹拖入此处")}</span>
|
||||
<button className="act" data-action="extensions.pick-folder" onClick={() => void pick()}>
|
||||
<button className="act" data-action="extensions.pick-folder" disabled={busy} onClick={() => void pick()}>
|
||||
{t("选文件夹")}
|
||||
</button>
|
||||
<button className="act" data-action="extensions.cancel" onClick={onClose}>
|
||||
@@ -223,7 +245,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
|
||||
{t(busy ? "读取中…" : "查看内容")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
{error && <div className="why" role="alert">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -244,9 +266,9 @@ export function Candidate({ a }: { a: PluginAction }) {
|
||||
</div>
|
||||
{runs.map((r) => (
|
||||
<div className="risk" data-kind="shell" key={r.label}>
|
||||
<span className="lb">{r.label}</span>
|
||||
<span className="lb">{t(r.label)}</span>
|
||||
<span className="dt">{r.detail}</span>
|
||||
{r.why && <span className="why">{r.why}</span>}
|
||||
{r.why && <span className="why">{t(r.why)}</span>}
|
||||
</div>
|
||||
))}
|
||||
{adds.length > 0 && (
|
||||
@@ -293,13 +315,13 @@ function executes(a: PluginAction): { label: string; detail: string; why?: strin
|
||||
});
|
||||
}
|
||||
if (a.runtime?.tools?.length) {
|
||||
out.push({ label: "新增工具", detail: a.runtime.tools.join("、"), why: "agent 可以调用,每次调用照常经过权限确认" });
|
||||
out.push({ label: "新增工具", detail: a.runtime.tools.join(language() === "zh" ? "、" : ", "), why: "agent 可以调用,每次调用照常经过权限确认" });
|
||||
}
|
||||
if (a.hookCount) {
|
||||
out.push({ label: "自动化钩子", detail: `${a.hookCount} 条`, why: "在会话生命周期内自动执行" });
|
||||
out.push({ label: "自动化钩子", detail: language() === "zh" ? t("{n} 条", { n: a.hookCount }) : plural(a.hookCount, "1 条钩子", "{n} 条钩子"), why: "在会话生命周期内自动执行" });
|
||||
}
|
||||
if (a.toolCount) {
|
||||
out.push({ label: "外部服务", detail: `${a.toolCount} 个 MCP 服务`, why: "为 agent 提供的能力与内置工具等同" });
|
||||
out.push({ label: "外部服务", detail: plural(a.toolCount, "1 个 MCP 服务", "{n} 个 MCP 服务"), why: "为 agent 提供的能力与内置工具等同" });
|
||||
}
|
||||
if (a.kind === "mcp" && a.command) {
|
||||
out.push({ label: "将启动", detail: [a.command, ...(a.args ?? [])].join(" ") });
|
||||
@@ -320,29 +342,29 @@ function version(actions?: PluginAction[]): string {
|
||||
function newlyGained(current: PluginPackage, actions: PluginAction[]): string[] {
|
||||
const plugin = actions.find((a) => a.kind === "plugin");
|
||||
const out: string[] = [];
|
||||
if (plugin?.runtime && !current.runtime) out.push("一个常驻进程");
|
||||
if (plugin?.runtime && !current.runtime) out.push(t("一个常驻进程"));
|
||||
const tools = (plugin?.runtime?.tools ?? []).filter((name) => !current.runtime?.tools?.includes(name));
|
||||
if (tools.length > 0) out.push(`工具 ${tools.join("、")}`);
|
||||
if (tools.length > 0) out.push(t("工具 {names}", { names: tools.join(language() === "zh" ? "、" : ", ") }));
|
||||
const hooks = (plugin?.hookCount ?? 0) - (current.hooks?.length ?? 0);
|
||||
if (hooks > 0) out.push(`${hooks} 条钩子`);
|
||||
if (hooks > 0) out.push(plural(hooks, "一条钩子", "{n} 条钩子"));
|
||||
const servers = (plugin?.toolCount ?? 0) - (current.mcpServers?.length ?? 0);
|
||||
if (servers > 0) out.push(`${servers} 个外部服务`);
|
||||
if (servers > 0) out.push(plural(servers, "一个外部服务", "{n} 个外部服务"));
|
||||
// A separate MCP action installs a server of its own, outside the package.
|
||||
const standalone = actions.filter((a) => a.kind === "mcp").length;
|
||||
if (standalone > 0) out.push(`${standalone} 个独立的 MCP 服务`);
|
||||
if (standalone > 0) out.push(plural(standalone, "一个独立的 MCP 服务", "{n} 个独立的 MCP 服务"));
|
||||
return out;
|
||||
}
|
||||
|
||||
function contributes(a: PluginAction): string[] {
|
||||
const parts: string[] = [];
|
||||
const add = (n: number | undefined, unit: string) => {
|
||||
if (n) parts.push(`${n} ${unit}`);
|
||||
const add = (n: number | undefined, one: string, many: string) => {
|
||||
if (n) parts.push(plural(n, one, many));
|
||||
};
|
||||
add(a.skillCount, "个技能");
|
||||
add(a.commandCount, "个命令");
|
||||
add(a.agentCount, "个子代理");
|
||||
add(a.promptCount, "个提示词");
|
||||
add(a.themeCount, "套配色");
|
||||
add(a.skillCount, "1 个技能", "{n} 个技能");
|
||||
add(a.commandCount, "1 个命令", "{n} 个命令");
|
||||
add(a.agentCount, "1 个子代理", "{n} 个子代理");
|
||||
add(a.promptCount, "1 个提示词", "{n} 个提示词");
|
||||
add(a.themeCount, "1 套配色", "{n} 套配色");
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -350,16 +372,28 @@ function contributes(a: PluginAction): string[] {
|
||||
// leaves the package on disk and out of this session, and saying so is the
|
||||
// difference between waiting and re-installing.
|
||||
export function Outcome({ plan }: { plan: PluginPlan }) {
|
||||
const state = plan.reloadError ? "action_required" : plan.ok ? "ready" : "issue";
|
||||
return (
|
||||
const state = !plan.ok ? "issue" : plan.reloadError ? "action_required" : "ready";
|
||||
const failed = plan.actions?.filter((action) => action.status === "failed") ?? [];
|
||||
return <>
|
||||
<div className="outcome" data-state={state}>
|
||||
<i className="pip" />
|
||||
<span className="nm">{plan.actions?.[0]?.name || t("安装")}</span>
|
||||
<span className="dt">
|
||||
{state === "ready" && t("装好了,下一轮就能用")}
|
||||
{state === "action_required" && t("装好了,但这一轮还在跑:等它结束或新建会话后生效")}
|
||||
{state === "issue" && (plan.error || plan.next || t("没装上"))}
|
||||
{state === "action_required" && t("装好了,但运行时未重载:{reason}。请用「重载运行时」重试。", { reason: plan.reloadError! })}
|
||||
{state === "issue" && (plan.error || (failed.length ? t("有项目未安装成功,原因见下方。") : plan.next || t("没装上")))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
{failed.map((action, i) => (
|
||||
<div className="why" role="alert" key={`${action.kind}:${action.name}:${i}`}>
|
||||
{action.name || action.kind}: {action.error || action.next || t("没装上")}
|
||||
{action.error && action.next && <div>{action.next}</div>}
|
||||
</div>
|
||||
))}
|
||||
{state === "issue" && plan.reloadError && (
|
||||
<div className="why" role="status">
|
||||
{t("运行时未重载:{reason}。请用「重载运行时」重试。", { reason: plan.reloadError })}
|
||||
</div>
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -64,3 +64,130 @@ it("keeps relay-only controls optional and saves them when disclosed", async ()
|
||||
headers: { "X-Title": "Reasonix" },
|
||||
}));
|
||||
});
|
||||
|
||||
const nameCatalog = async () => [{ kind: "openai", discovery: "openai", serverWebSearch: false, reasoningParams: true }];
|
||||
|
||||
it("explains an unusable source name next to the field while it is typed and holds the add button", async () => {
|
||||
const saveProvider = vi.fn(async () => {});
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
const field = screen.getByLabelText("来源名称");
|
||||
await userEvent.type(field, "公司中转站");
|
||||
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
|
||||
|
||||
expect(field.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByText(/名称只能用字母、数字、点、连字符和下划线/)).toBeTruthy();
|
||||
expect((screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByText("无法连接")).toBeNull();
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "company-relay");
|
||||
expect(field.getAttribute("aria-invalid")).toBeNull();
|
||||
expect((screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a coded name refusal from the kernel on the name field, not as a connection failure", async () => {
|
||||
const { HttpError } = await import("../port/http_error");
|
||||
const saveProvider = vi.fn(async () => {
|
||||
throw new HttpError(409, "taken", { code: "provider.name_taken", params: { name: "relay" } });
|
||||
});
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
await userEvent.type(screen.getByLabelText("来源名称"), "relay");
|
||||
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加来源" }));
|
||||
|
||||
const field = screen.getByLabelText("来源名称");
|
||||
await waitFor(() => expect(field.getAttribute("aria-invalid")).toBe("true"));
|
||||
expect(screen.getByText("已经有名为「relay」的连接了,换一个名称")).toBeTruthy();
|
||||
expect(screen.queryByText("无法连接")).toBeNull();
|
||||
expect(screen.queryByText("无法保存")).toBeNull();
|
||||
});
|
||||
|
||||
it("titles a failed save as a save failure, never as a connection failure", async () => {
|
||||
const { HttpError } = await import("../port/http_error");
|
||||
const saveProvider = vi.fn(async () => {
|
||||
throw new HttpError(422, "pick at least one model", { code: "provider.endpoint_required" });
|
||||
});
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
await userEvent.type(screen.getByLabelText("来源名称"), "relay");
|
||||
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加来源" }));
|
||||
|
||||
expect(await screen.findByText("无法保存")).toBeTruthy();
|
||||
expect(screen.queryByText("无法连接")).toBeNull();
|
||||
});
|
||||
|
||||
it("ties the name rule to the field and the button, and waits out IME composition", async () => {
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider: vi.fn() } as unknown as Port;
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
const field = screen.getByLabelText("来源名称");
|
||||
expect(field.getAttribute("aria-describedby")).toBe("addp-name-rule");
|
||||
|
||||
fireEvent.compositionStart(field);
|
||||
fireEvent.change(field, { target: { value: "gong'si" } });
|
||||
expect(field.getAttribute("aria-invalid")).toBeNull();
|
||||
fireEvent.compositionEnd(field);
|
||||
expect(field.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "添加来源" }).getAttribute("aria-describedby")).toBe("addp-name-rule");
|
||||
});
|
||||
|
||||
it("keeps Add disabled while an IME name is still being composed", async () => {
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
const saveProvider = vi.fn();
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
|
||||
const name = screen.getByLabelText("来源名称");
|
||||
fireEvent.change(name, { target: { value: "company-relay" } });
|
||||
fireEvent.change(screen.getByLabelText("接口地址"), { target: { value: "https://relay.example/v1" } });
|
||||
const model = screen.getByRole("searchbox", { name: "搜索或添加模型" });
|
||||
fireEvent.change(model, { target: { value: "m1" } });
|
||||
fireEvent.keyDown(model, { key: "Enter", code: "Enter", charCode: 13 });
|
||||
const add = screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement;
|
||||
await waitFor(() => expect(add.disabled).toBe(false));
|
||||
|
||||
fireEvent.compositionStart(name);
|
||||
fireEvent.change(name, { target: { value: "公司中转站" } });
|
||||
expect(add.disabled).toBe(true);
|
||||
fireEvent.click(add);
|
||||
expect(saveProvider).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.compositionEnd(name);
|
||||
expect(add.disabled).toBe(true);
|
||||
fireEvent.change(name, { target: { value: "company-relay" } });
|
||||
expect(add.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes spaces around an ASCII name and refuses a blank one", async () => {
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
const saveProvider = vi.fn(async () => {});
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
|
||||
const name = screen.getByLabelText("来源名称");
|
||||
const add = screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement;
|
||||
expect(add.disabled).toBe(true);
|
||||
fireEvent.change(name, { target: { value: " " } });
|
||||
expect(add.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(name, { target: { value: " company-relay.1_x " } });
|
||||
fireEvent.change(screen.getByLabelText("接口地址"), { target: { value: "https://relay.example/v1" } });
|
||||
const model = screen.getByRole("searchbox", { name: "搜索或添加模型" });
|
||||
fireEvent.change(model, { target: { value: "m1" } });
|
||||
fireEvent.keyDown(model, { key: "Enter", code: "Enter", charCode: 13 });
|
||||
await waitFor(() => expect(add.disabled).toBe(false));
|
||||
|
||||
fireEvent.click(add);
|
||||
await waitFor(() => expect(saveProvider).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "company-relay.1_x",
|
||||
})));
|
||||
});
|
||||
@@ -2,14 +2,17 @@ import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { Protocol, ProviderEntry, ProviderProbe } from "../port/port";
|
||||
import { clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
|
||||
import { KIND_LABEL, hostOf, nameFrom, vendorLabel } from "./vendors";
|
||||
import { KIND_LABEL, hostOf, nameFrom, sourceNameUsable, vendorLabel } from "./vendors";
|
||||
import type { Port } from "./Providers";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import { THINKING, parseExtraBody, parseHeaders } from "./provider_compat";
|
||||
|
||||
// Detection is assistance, not authority. Compatible relays often expose one
|
||||
// model-list shape while expecting another request protocol, so every durable
|
||||
// value remains editable and a failed probe never blocks manual setup.
|
||||
const NAME_CODES = new Set(["provider.name_required", "provider.name_invalid", "provider.name_taken"]);
|
||||
|
||||
export function AddProvider({
|
||||
port, taken, known, onDone, onCancel,
|
||||
}: {
|
||||
@@ -20,6 +23,9 @@ export function AddProvider({
|
||||
const [probe, setProbe] = useState<ProviderProbe | null>(null);
|
||||
const [catalog, setCatalog] = useState<Protocol[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [errOnSave, setErrOnSave] = useState(false);
|
||||
const [nameErr, setNameErr] = useState("");
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// The endpoint says which wires are on the table and the kernel says what
|
||||
@@ -52,6 +58,7 @@ export function AddProvider({
|
||||
const [heads, setHeads] = useState("");
|
||||
const [extra, setExtra] = useState("");
|
||||
const [noProxy, setNoProxy] = useState(false);
|
||||
const nameBad = !composing && name.trim() !== "" && !sourceNameUsable(name);
|
||||
const extraBad = extra.trim() !== "" && parseExtraBody(extra) === null;
|
||||
const protocolCanThink = catalog.find((p) => p.kind === kind)?.reasoningParams ?? false;
|
||||
// A wire with no listing shape has nothing to read: the model id is declared
|
||||
@@ -66,6 +73,7 @@ export function AddProvider({
|
||||
const connect = async () => {
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
setErrOnSave(false);
|
||||
try {
|
||||
const got = await port.probeProvider(baseUrl.trim(), apiKey.trim());
|
||||
setProbe(got);
|
||||
@@ -87,6 +95,8 @@ export function AddProvider({
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
setNameErr("");
|
||||
setErrOnSave(true);
|
||||
try {
|
||||
await port.saveProvider({
|
||||
name: name.trim(),
|
||||
@@ -107,7 +117,8 @@ export function AddProvider({
|
||||
});
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(reason(e));
|
||||
if (e instanceof HttpError && e.reason?.code && NAME_CODES.has(e.reason.code)) setNameErr(reason(e));
|
||||
else setErr(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -167,18 +178,27 @@ export function AddProvider({
|
||||
<button className="addp-close" onClick={onCancel} disabled={busy || checkingModel !== ""} aria-label={t("取消")}>×</button>
|
||||
</div>
|
||||
<div className="fields">
|
||||
<label className="grow">
|
||||
<div className="grow name-cell">
|
||||
<label>
|
||||
<span>{t("来源名称")}</span>
|
||||
<input
|
||||
aria-label={t("来源名称")}
|
||||
data-action="provider.draft" data-value="name"
|
||||
value={name}
|
||||
placeholder={t("例如:公司中转站")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => { setName(e.target.value); setNameErr(""); }}
|
||||
disabled={busy || checkingModel !== ""}
|
||||
spellCheck={false}
|
||||
aria-invalid={nameBad || nameErr !== "" || undefined}
|
||||
aria-describedby="addp-name-rule"
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
/>
|
||||
</label>
|
||||
<p id="addp-name-rule" className="name-rule" data-bad={nameBad || nameErr !== "" || undefined} aria-live="polite">
|
||||
{nameErr !== "" ? nameErr : t("名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="grow">
|
||||
<span>{t("接口协议")}</span>
|
||||
<select aria-label={t("接口协议")} data-action="provider.draft" data-value="protocol" value={kind} onChange={(e) => {
|
||||
@@ -333,7 +353,7 @@ export function AddProvider({
|
||||
</div>
|
||||
|
||||
<div className="acts addp-footer">
|
||||
<button className="act" data-action="provider.add" data-primary onClick={save} disabled={busy || checkingModel !== "" || picked.length === 0 || name.trim() === "" || kind === "" || baseUrl.trim() === "" || extraBad}>
|
||||
<button className="act" data-action="provider.add" data-primary onClick={save} aria-describedby={nameBad ? "addp-name-rule" : undefined} disabled={busy || composing || checkingModel !== "" || picked.length === 0 || name.trim() === "" || nameBad || kind === "" || baseUrl.trim() === "" || extraBad}>
|
||||
{t(busy ? "保存中…" : "添加来源")}
|
||||
</button>
|
||||
<button className="act" onClick={onCancel} disabled={busy || checkingModel !== ""}>{t("取消")}</button>
|
||||
@@ -341,7 +361,7 @@ export function AddProvider({
|
||||
|
||||
{err && (
|
||||
<div className="find" data-lvl="warn">
|
||||
<span className="t">{t("无法连接")}</span>
|
||||
<span className="t">{errOnSave ? t("无法保存") : t("无法连接")}</span>
|
||||
<span className="why">{err}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,13 @@ interface Props {
|
||||
onInstalled: () => void;
|
||||
}
|
||||
|
||||
export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
export function AddServer(props: Props) {
|
||||
const [connection, setConnection] = useState({ port: props.port, generation: 0 });
|
||||
if (connection.port !== props.port) setConnection({ port: props.port, generation: connection.generation + 1 });
|
||||
return <ServerInput key={connection.generation} {...props} />;
|
||||
}
|
||||
|
||||
function ServerInput({ port, canProject, onClose, onInstalled }: Props) {
|
||||
// Mounted only while open, so this component is the layer Escape closes.
|
||||
useEscape(true, onClose);
|
||||
const [text, setText] = useState("");
|
||||
@@ -35,8 +41,11 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [results, setResults] = useState<McpInstallResult[]>([]);
|
||||
const remaining = draft?.servers.filter((s) => !results.some((r) => r.name === s.name && r.state !== "issue")) ?? [];
|
||||
const hasSaved = results.some((r) => r.state !== "issue");
|
||||
|
||||
const parse = async () => {
|
||||
if (busy || !text.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -53,19 +62,19 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const out: McpInstallResult[] = [];
|
||||
try {
|
||||
const out: McpInstallResult[] = [];
|
||||
for (const s of draft.servers) out.push(await port.installMcp(s, scope));
|
||||
setResults(out);
|
||||
if (out.some((r) => r.state !== "issue")) onInstalled();
|
||||
for (const s of remaining) out.push(await port.installMcp(s, scope));
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setResults((previous) => [...new Map([...previous, ...out].map((r) => [r.name, r])).values()]);
|
||||
setBusy(false);
|
||||
if (out.some((r) => r.state !== "issue")) onInstalled();
|
||||
}
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
if (results.length > 0 && remaining.length === 0 && !error && !busy) {
|
||||
return (
|
||||
<div className="addsrv" data-stage="done">
|
||||
{results.map((r) => (
|
||||
@@ -81,7 +90,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="addsrv" data-stage={draft ? "confirm" : "paste"}>
|
||||
<div className="addsrv" data-stage={draft ? "confirm" : "paste"} aria-busy={busy}>
|
||||
{!draft && (
|
||||
<>
|
||||
<textarea
|
||||
@@ -93,7 +102,10 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
placeholder={PLACEHOLDER}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void parse();
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void parse();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="acts">
|
||||
@@ -110,7 +122,8 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
{draft.servers.map((s) => (
|
||||
{results.map((r) => <Outcome key={r.name} r={r} />)}
|
||||
{remaining.map((s) => (
|
||||
<div className="cand" key={s.name}>
|
||||
<div className="cand-hd">
|
||||
<span className="nm">{s.name}</span>
|
||||
@@ -131,13 +144,13 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
question. Only the third option edits a tracked file, so it is the
|
||||
one that has to say so out loud — and it is never the default. */}
|
||||
<div className="scope" role="radiogroup" aria-label={t("安装位置")}>
|
||||
<button role="radio" aria-checked={scope === "user"} onClick={() => setScope("user")}>
|
||||
<button role="radio" aria-checked={scope === "user"} disabled={busy || hasSaved} onClick={() => setScope("user")}>
|
||||
{t("我的")}<i>{t("所有项目均可使用")}</i>
|
||||
</button>
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={scope === "local"}
|
||||
disabled={!canProject}
|
||||
disabled={busy || hasSaved || !canProject}
|
||||
onClick={() => setScope("local")}
|
||||
>
|
||||
{t("仅当前项目")}<i>{t("不写入仓库,他人不会获得")}</i>
|
||||
@@ -145,7 +158,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={scope === "project"}
|
||||
disabled={!canProject}
|
||||
disabled={busy || hasSaved || !canProject}
|
||||
onClick={() => setScope("project")}
|
||||
>
|
||||
{t("写进仓库")}<i>{t("clone 仓库的人也会获得")}</i>
|
||||
@@ -155,7 +168,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
<div className="warn">{t("这会修改仓库中的配置文件,属于一处待提交的改动。")}</div>
|
||||
)}
|
||||
<div className="acts">
|
||||
<button className="act" onClick={() => setDraft(null)}>
|
||||
<button className="act" disabled={busy} onClick={() => { setDraft(null); setResults([]); setError(""); }}>
|
||||
{t("返回")}
|
||||
</button>
|
||||
<button className="act" data-action="mcp.add" data-primary disabled={busy} onClick={() => void install()}>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
|
||||
interface Props {
|
||||
busy: boolean;
|
||||
onSubmit: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** The picker-free path into a workspace. It only appears when the kernel says
|
||||
* it has no native picker, so a desktop shell keeps its familiar dialog. */
|
||||
export function AddWorkspacePrompt({ busy, onSubmit, onClose }: Props) {
|
||||
const [path, setPath] = useState("");
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
input.current?.focus();
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div className="pickveil" role="dialog" aria-modal="true" aria-labelledby="addws-t">
|
||||
<form
|
||||
className="pickcard"
|
||||
data-action="workspace.add"
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
onSubmit(path);
|
||||
}}
|
||||
>
|
||||
<h2 id="addws-t">{t("添加工作区")}</h2>
|
||||
<p className="picknote">{t("无法打开文件夹选择器。请输入内核所在机器上的路径。")}</p>
|
||||
<div className="pickpath">
|
||||
<input
|
||||
ref={input}
|
||||
value={path}
|
||||
spellCheck={false}
|
||||
dir="ltr"
|
||||
placeholder="/srv/project"
|
||||
aria-label={t("工作区路径")}
|
||||
data-action="workspace.add"
|
||||
onChange={(ev) => setPath(ev.target.value)}
|
||||
/>
|
||||
<button type="submit" className="pickgo" data-action="workspace.add" disabled={busy || !path.trim()}>
|
||||
{t("添加")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pickact">
|
||||
<button type="button" data-action="workspace.add-cancel" disabled={busy} onClick={onClose}>{t("取消")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { folded as roomGaveUp, onRoomWidth } from "./viewport";
|
||||
import { useFoldAway } from "./foldaway";
|
||||
import { useDrawerCloses } from "./drawer";
|
||||
import { RemoteAsk } from "./RemoteAsk";
|
||||
import { Feedback, type FeedbackTab } from "./Feedback";
|
||||
import { BrowserLogin } from "./BrowserLogin";
|
||||
import type { RemoteAsk as RemoteAskT, RemoteHost } from "../port/remote";
|
||||
import { Boundary } from "./Boundary";
|
||||
@@ -24,6 +25,7 @@ import { usePaint } from "./paint";
|
||||
import { rememberActivePane, savedActivePane } from "./activepane";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Sky } from "./Sky";import { useAddWorkspace } from "./addws";
|
||||
import { AddWorkspacePrompt } from "./AddWorkspacePrompt";
|
||||
import { PaneTabs } from "./PaneTabs";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
import { Welcome } from "./Welcome";
|
||||
@@ -104,6 +106,8 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
const [error, setError] = useState("");
|
||||
// false = closed, true = open at its last section, a string = open there.
|
||||
const [settings, setSettings] = useState<string | boolean>(false);
|
||||
const [feedback, setFeedback] = useState<FeedbackTab | null>(null);
|
||||
const [feedbackUnread, setFeedbackUnread] = useState(0);
|
||||
const [browser, setBrowser] = useState(false);
|
||||
const [setup, setSetup] = useState<ProviderSetup | null | undefined>(undefined);
|
||||
// undefined until asked; false means the opening sequence is still owed.
|
||||
@@ -254,6 +258,20 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
|
||||
useLaunchHealth(activePort, setup, welcomed);
|
||||
|
||||
// Asked once when the app opens and again each time the page is opened,
|
||||
// never on a timer: there is no push channel to keep honest.
|
||||
const feedbackPort = networkPort ?? activePort;
|
||||
const feedbackAsk = useRef(feedbackPort);
|
||||
feedbackAsk.current = feedbackPort;
|
||||
const feedbackReady = feedbackPort !== null;
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
feedbackAsk.current?.myFeedback().then((m) => alive && setFeedbackUnread(m.unread)).catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [feedbackReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePort) return;
|
||||
let alive = true;
|
||||
@@ -638,6 +656,8 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
accountUnread={accountUnread}
|
||||
wallet={report.wallet}
|
||||
onSettings={showPrefs}
|
||||
onFeedback={(tab) => setFeedback(tab ?? "send")}
|
||||
feedbackUnread={feedbackUnread}
|
||||
onFind={openFind}
|
||||
onError={fail}
|
||||
/>
|
||||
@@ -735,6 +755,12 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
|
||||
</div>
|
||||
|
||||
{adder.pathOpen && <AddWorkspacePrompt busy={adder.busy} onSubmit={adder.addPath} onClose={adder.closePath} />}
|
||||
|
||||
{feedback && (networkPort ?? activePort) && (
|
||||
<Feedback port={(networkPort ?? activePort)!} tab={feedback} onClose={() => setFeedback(null)} onError={setError} onUnread={setFeedbackUnread} />
|
||||
)}
|
||||
|
||||
{settings && activePort && (
|
||||
<Boundary fallback={<SettingsUnavailable onClose={hidePrefs} />}>
|
||||
<Suspense fallback={<div className="prefs" aria-busy="true" />}>
|
||||
|
||||
Loaded 100 of 745 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user