mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
210
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d338338d54 | ||
|
|
8df12a049a | ||
|
|
ab25352696 | ||
|
|
1fa63d16f0 | ||
|
|
e40b3b070f | ||
|
|
fc2887582e | ||
|
|
8f8785c4c4 | ||
|
|
e6d5dab212 | ||
|
|
403ae88dd2 | ||
|
|
f7eb4e26a6 | ||
|
|
a4e456a43e | ||
|
|
42455e61fb | ||
|
|
677e3eabe2 | ||
|
|
090060507c | ||
|
|
cb1b36d726 | ||
|
|
255001cead | ||
|
|
7ed449841b | ||
|
|
28455421fb | ||
|
|
8cfb5f75c7 | ||
|
|
116d0b4176 | ||
|
|
fc1f7fdcfa | ||
|
|
9d5f2a7dd9 | ||
|
|
63754d56d2 | ||
|
|
0e06829846 | ||
|
|
54f4fbb19f | ||
|
|
03147a71cd | ||
|
|
65a06434fb | ||
|
|
fdba7c3210 | ||
|
|
254140a866 | ||
|
|
b02849f54f | ||
|
|
66bfceb85b | ||
|
|
39fe0acdc5 | ||
|
|
931b9aea42 | ||
|
|
cb2485a0b8 | ||
|
|
172d05191a | ||
|
|
8c87da114b | ||
|
|
8634cf3da2 | ||
|
|
0a66bd417d | ||
|
|
72030d75a3 | ||
|
|
5dfcc2d086 | ||
|
|
42d4ab374c | ||
|
|
2921ddff26 | ||
|
|
750ed087db | ||
|
|
a2a5855f23 | ||
|
|
9052fd32f6 | ||
|
|
3a3b474b10 | ||
|
|
8fb66a86da | ||
|
|
2766f23beb | ||
|
|
58a94b30d6 | ||
|
|
7db5d0af31 | ||
|
|
779fe8a04d | ||
|
|
25eff5352d | ||
|
|
0a96b4bf8e | ||
|
|
c24586d34d | ||
|
|
3c96792e13 | ||
|
|
294d179cb5 | ||
|
|
adde7dd6a7 | ||
|
|
610e28f6d7 | ||
|
|
27d6e6f2ad | ||
|
|
7f453a62f3 | ||
|
|
5fcf468fb0 | ||
|
|
abd863407f | ||
|
|
2d43186219 | ||
|
|
d30d9cec70 | ||
|
|
542a6076a3 | ||
|
|
692aecd6b7 | ||
|
|
c5b3dbbfe0 | ||
|
|
d550f754fc | ||
|
|
b3a5541069 | ||
|
|
63195e95a5 | ||
|
|
878fc8a53d | ||
|
|
f7d6d25930 | ||
|
|
0594ee9e0c | ||
|
|
290e787c47 | ||
|
|
dbb7b8498f | ||
|
|
4e7067a17b | ||
|
|
ac16c15950 | ||
|
|
c8f57aeb69 | ||
|
|
3134b0f18c | ||
|
|
6ab2051411 | ||
|
|
6a9a821998 | ||
|
|
3083ef303d | ||
|
|
5bba3bf0b4 | ||
|
|
35da5f70be | ||
|
|
27f78af346 | ||
|
|
ae6f621bb8 | ||
|
|
28dd27d033 | ||
|
|
6ee1bbd3aa | ||
|
|
c8152a8f7e | ||
|
|
0487b131a0 | ||
|
|
87d1ebd1d4 | ||
|
|
5b4b4ccb97 | ||
|
|
ab8835393f | ||
|
|
dc809e1150 | ||
|
|
2bfe43a62d | ||
|
|
81ce4da64b | ||
|
|
6a335ae9d7 | ||
|
|
f89c934e47 | ||
|
|
bfe2ca68b8 | ||
|
|
c3837f5a93 | ||
|
|
2abe2784e3 | ||
|
|
0d7fd85452 | ||
|
|
ac31510363 | ||
|
|
753996a897 | ||
|
|
2f45ce03f3 | ||
|
|
4e4004f0f4 | ||
|
|
939ff9f6f7 | ||
|
|
feb4a51051 | ||
|
|
69464792d2 | ||
|
|
75c640b9d2 | ||
|
|
a970662e35 | ||
|
|
19c86553ae | ||
|
|
2719b42f8b | ||
|
|
a15b269c7f | ||
|
|
597153d377 | ||
|
|
ebe758959e | ||
|
|
ae22f4b171 | ||
|
|
0b638eb96e | ||
|
|
7c2a81eb24 | ||
|
|
b3d091139f | ||
|
|
6913fc9765 | ||
|
|
18ed0649c9 | ||
|
|
90afa32c27 | ||
|
|
7363b44c25 | ||
|
|
f74ae6d352 | ||
|
|
c5b43c410c | ||
|
|
080a8d7ac1 | ||
|
|
86ba9227ee | ||
|
|
9b1ec952e1 | ||
|
|
c28884609c | ||
|
|
5f7827c9b2 | ||
|
|
711966ef31 | ||
|
|
fb297c3483 | ||
|
|
99ee5cdfe0 | ||
|
|
fc2f4b495c | ||
|
|
1a38708fda | ||
|
|
6a508efac6 | ||
|
|
11b0513747 | ||
|
|
47d219e966 | ||
|
|
72d4e9f28e | ||
|
|
46783d365f | ||
|
|
d486cfe71f | ||
|
|
0f8dfa34f9 | ||
|
|
dabc3481d2 | ||
|
|
0195f8241a | ||
|
|
bc13eddb53 | ||
|
|
d98832f695 | ||
|
|
310ffda281 | ||
|
|
d7744a1616 | ||
|
|
0da518ce84 | ||
|
|
358fb4cff2 | ||
|
|
7aeae1990d | ||
|
|
b2db65b417 | ||
|
|
43372e6ce7 | ||
|
|
b650e362df | ||
|
|
5bfdfb0bd6 | ||
|
|
3c060de3e2 | ||
|
|
de789afafb | ||
|
|
c83f5931fc | ||
|
|
028179b37f | ||
|
|
880dcc5b64 | ||
|
|
8576a0b0ff | ||
|
|
37056a8dba | ||
|
|
a078f8e30f | ||
|
|
875dd8e67a | ||
|
|
558b9cbbb2 | ||
|
|
461e03770f | ||
|
|
dbc79359c0 | ||
|
|
5dc827a1c0 | ||
|
|
9de8fe7374 | ||
|
|
075db5672f | ||
|
|
3dd8fd4dfa | ||
|
|
4a82273735 | ||
|
|
65f5a0c6f3 | ||
|
|
7e469c1c59 | ||
|
|
e190049a70 | ||
|
|
a3fd97ab74 | ||
|
|
b8e25a4bb4 | ||
|
|
98e1ebe28f | ||
|
|
963fd1eec1 | ||
|
|
09ba9cddc5 | ||
|
|
599ba7cb77 | ||
|
|
4e41da5a81 | ||
|
|
8e87a1557b | ||
|
|
39fddef450 | ||
|
|
8f61e66cf1 | ||
|
|
00c97a6036 | ||
|
|
94d1a293f5 | ||
|
|
c66775519f | ||
|
|
65c7316669 | ||
|
|
a4b5a9a485 | ||
|
|
8e5a124070 | ||
|
|
6d4b5dcb6a | ||
|
|
c0856e8231 | ||
|
|
2e9045a300 | ||
|
|
8ecfa8c084 | ||
|
|
cf490285b0 | ||
|
|
ff8e467102 | ||
|
|
54c8e7ab68 | ||
|
|
bce7224270 | ||
|
|
a0cc22bfc0 | ||
|
|
3552dc1412 | ||
|
|
5a10bb1d7f | ||
|
|
63bbb1334d | ||
|
|
d2d5571a00 | ||
|
|
e730dafd27 | ||
|
|
e1ca40f84a | ||
|
|
f8843f4b2c | ||
|
|
158d9e2b18 | ||
|
|
49dba2381a |
No files matched your search
@@ -62,7 +62,7 @@ jobs:
|
||||
# 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; npm/ holds the publish tests.
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
@@ -80,7 +80,11 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- 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
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/cli-archive-build.test.mjs 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/brew-cask-guard.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
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -757,14 +757,18 @@ jobs:
|
||||
- name: Cross-compile and archive
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
pkg=reasonix/internal/tools/productdocs
|
||||
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
os="${target%/*}"; arch="${target#*/}"; ext=""
|
||||
[ "$os" = windows ] && ext=".exe"
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$VERSION" -o "reasonix$ext" ./cmd/reasonix
|
||||
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
|
||||
-o "reasonix$ext" ./cmd/reasonix
|
||||
if [ "$os" = windows ]; then
|
||||
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
|
||||
else
|
||||
@@ -1211,13 +1215,13 @@ jobs:
|
||||
|
||||
# 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
|
||||
# exists, because the cask points at its archives. A candidate goes to npm's
|
||||
# `next` tag and leaves the tap alone: brew has no prerelease channel, so a
|
||||
# candidate cask would become the default install.
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
|
||||
# created the CLI release, because the cask points at its archives. A candidate
|
||||
# goes to npm's `next` tag and leaves the tap alone: brew has no prerelease
|
||||
# channel, so a candidate cask would become the default install.
|
||||
cli-channels:
|
||||
name: publish the CLI to npm and Homebrew
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
needs: [resolve, publish, cli-gate, cli-tag, cli-pointer]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# id-token lets npm publish --provenance attach a Sigstore attestation; the
|
||||
@@ -1256,6 +1260,18 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
|
||||
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
|
||||
case "$decision" in
|
||||
write) ;;
|
||||
skip)
|
||||
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "::error::unexpected cask guard decision: '$decision'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
|
||||
cd tap
|
||||
git add Casks/reasonix.rb
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
mathrand "math/rand"
|
||||
"strings"
|
||||
)
|
||||
@@ -90,15 +91,25 @@ func seededRand(parts ...string) *mathrand.Rand {
|
||||
// liveRand is the generator a paid run uses: every run gets values that existed
|
||||
// nowhere before it started.
|
||||
func liveRand() *mathrand.Rand {
|
||||
var seed [8]byte
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
rng, err := liveRandFrom(rand.Reader)
|
||||
if err != nil {
|
||||
panic("contextbench: no entropy for a live run: " + err.Error())
|
||||
}
|
||||
return rng
|
||||
}
|
||||
|
||||
// liveRandFrom seeds a generator from an entropy source, so what a run's values
|
||||
// are unpredictable from is the source and nothing else.
|
||||
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
|
||||
var seed [8]byte
|
||||
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var n int64
|
||||
for _, b := range seed {
|
||||
n = n<<8 | int64(b)
|
||||
}
|
||||
return mathrand.New(mathrand.NewSource(n))
|
||||
return mathrand.New(mathrand.NewSource(n)), nil
|
||||
}
|
||||
|
||||
// instantiate substitutes {{name}} and refuses anything left over. The grammar
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
mathrand "math/rand"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -82,20 +85,46 @@ func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two runs of the same task must not share an answer, or one leaked run leaks
|
||||
// every future one.
|
||||
func TestInstancesDifferBetweenRuns(t *testing.T) {
|
||||
// A run's values are unpredictable from a previous run only if the live
|
||||
// generator is seeded from entropy. Comparing answers across two instances
|
||||
// cannot assert that: a task whose answer is a bounded number collides by
|
||||
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
|
||||
func TestLiveRandDrawsFromEntropy(t *testing.T) {
|
||||
a, b := liveRand(), liveRand()
|
||||
for range 4 {
|
||||
if a.Int63() != b.Int63() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("two live generators produced the same stream: they are seeded alike")
|
||||
}
|
||||
|
||||
// The source alone decides an instance: the same entropy gives the same
|
||||
// answers, different entropy gives different values.
|
||||
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
|
||||
same := func() *mathrand.Rand {
|
||||
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rng
|
||||
}
|
||||
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
|
||||
t.Fatal("an empty entropy source was accepted")
|
||||
}
|
||||
for _, task := range allTasks() {
|
||||
a, err := instantiateTask(task, liveRand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
a, _ := instantiateTask(task, same())
|
||||
b, _ := instantiateTask(task, same())
|
||||
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
|
||||
t.Errorf("%s: the same entropy gave different instances", task.ID)
|
||||
}
|
||||
b, err := instantiateTask(task, liveRand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Equal(a.AnswerMarkers, b.AnswerMarkers) {
|
||||
t.Errorf("%s produced the same answers twice: %v", task.ID, a.AnswerMarkers)
|
||||
c, _ := instantiateTask(task, other)
|
||||
if maps.Equal(a.Vars, c.Vars) {
|
||||
t.Errorf("%s: different entropy gave the same values", task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
def wire_fee(amount):
|
||||
"""Return the wire fee for `amount`: the greater of 15.0 and 0.1% of it.
|
||||
|
||||
A zero or negative amount has no wire fee, so it returns 0.0.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0.0
|
||||
return max(15.0, amount * 0.001)
|
||||
@@ -0,0 +1,13 @@
|
||||
def convert(amount, rate):
|
||||
"""Convert `amount` at `rate` and return the product.
|
||||
|
||||
Both units belong to the caller. A negative amount converts unchanged.
|
||||
"""
|
||||
return amount * rate
|
||||
|
||||
def spread(bid, ask):
|
||||
"""Return `ask - bid`, the spread between the two quoted prices.
|
||||
|
||||
A negative result means the bid is above the ask.
|
||||
"""
|
||||
return ask - bid
|
||||
@@ -0,0 +1,4 @@
|
||||
EARTH_RADIUS_KM = 6371.0
|
||||
|
||||
def dist(left, right):
|
||||
return abs(left - right) * EARTH_RADIUS_KM
|
||||
@@ -0,0 +1,4 @@
|
||||
import geo
|
||||
|
||||
def near_by(point, other):
|
||||
return geo.dist(point, other) < 10
|
||||
@@ -0,0 +1,16 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
def upgrade(row):
|
||||
parts = row["fullname"].rsplit(" ", 1)
|
||||
out = {k: v for k, v in row.items() if k != "fullname"}
|
||||
out["first"] = parts[0]
|
||||
out["last"] = parts[1] if len(parts) > 1 else ""
|
||||
return out
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
print(json.dumps(upgrade(json.loads(line))))
|
||||
@@ -0,0 +1,7 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("orders")
|
||||
|
||||
def place(order, request_id):
|
||||
log.info("placing order %s", order["id"], extra={"request_id": request_id})
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,7 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("shipping")
|
||||
|
||||
def dispatch(order, request_id):
|
||||
log.info("dispatching %s", order["id"], extra={"request_id": request_id})
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,11 @@
|
||||
import math
|
||||
|
||||
def p95(sorted_values):
|
||||
if not sorted_values:
|
||||
return 0
|
||||
return sorted_values[math.ceil(len(sorted_values) * 0.95) - 1]
|
||||
|
||||
def rate(events, seconds):
|
||||
if seconds <= 0:
|
||||
return 0.0
|
||||
return len(events) / seconds
|
||||
@@ -0,0 +1,9 @@
|
||||
import core
|
||||
|
||||
_cache = None
|
||||
|
||||
def list_records_cached():
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = core.list_records()
|
||||
return _cache
|
||||
@@ -0,0 +1,5 @@
|
||||
import cache
|
||||
|
||||
def main():
|
||||
for k, v in sorted(cache.list_records_cached().items()):
|
||||
print(f"{k}={v}")
|
||||
@@ -0,0 +1,4 @@
|
||||
DATA = {"a": 1, "b": 2}
|
||||
|
||||
def list_records():
|
||||
return dict(DATA)
|
||||
@@ -0,0 +1 @@
|
||||
DEFAULTS = {"host": "localhost", "port": 8080}
|
||||
@@ -0,0 +1,6 @@
|
||||
import json
|
||||
import defaults
|
||||
|
||||
def load(text):
|
||||
data = json.loads(text) if text.strip() else {}
|
||||
return dict(defaults.DEFAULTS, **data)
|
||||
@@ -0,0 +1,4 @@
|
||||
import json
|
||||
|
||||
def dump(cfg):
|
||||
return json.dumps(cfg, sort_keys=True)
|
||||
@@ -0,0 +1,2 @@
|
||||
STATES = ("new", "paid", "delivered", "cancelled")
|
||||
TERMINAL = ("delivered", "cancelled")
|
||||
@@ -0,0 +1,11 @@
|
||||
import states
|
||||
|
||||
LEGAL = {
|
||||
"new": ("paid", "cancelled"),
|
||||
"paid": ("delivered", "cancelled"),
|
||||
"delivered": (),
|
||||
"cancelled": (),
|
||||
}
|
||||
|
||||
def can_move(a, b):
|
||||
return b in LEGAL[a]
|
||||
@@ -0,0 +1,5 @@
|
||||
import schema, serializer
|
||||
|
||||
def display_name(line):
|
||||
rec = serializer.load(line)
|
||||
return rec["handle"] or rec["email"]
|
||||
@@ -0,0 +1,4 @@
|
||||
FIELDS = ("handle", "email")
|
||||
|
||||
def blank():
|
||||
return {f: "" for f in FIELDS}
|
||||
@@ -0,0 +1,4 @@
|
||||
import timings
|
||||
|
||||
def is_slow(entry):
|
||||
return entry["elapsed_s"] > timings.SLOW_SECONDS
|
||||
@@ -0,0 +1,4 @@
|
||||
import timings
|
||||
|
||||
def within_budget(entries):
|
||||
return sum(e["elapsed_s"] for e in entries) <= timings.BUDGET_SECONDS
|
||||
@@ -0,0 +1,5 @@
|
||||
SLOW_SECONDS = 2.0
|
||||
BUDGET_SECONDS = 60.0
|
||||
|
||||
def record(seconds):
|
||||
return {"elapsed_s": seconds}
|
||||
@@ -250,10 +250,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
work := stageSeed(t, task.dir)
|
||||
if !stageSolved(t, task.dir, work) {
|
||||
if dir == trainCorpusDir {
|
||||
t.Fatal("no solution/: a generated task must prove its grader can pass")
|
||||
if dir == trainCorpusDir || dir == verificationStressDir {
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
}
|
||||
t.Skip("no solution/ to check this grader against")
|
||||
t.Skip("no solution/: the e2e suite does not commit reference solutions")
|
||||
}
|
||||
if err := gradeSeed(t, work); err != nil {
|
||||
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
|
||||
|
||||
@@ -86,6 +86,13 @@ func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
|
||||
t.Fatalf("%s does not wait for cli-tag", name)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, "cli-channels"), "needs").Content {
|
||||
found = found || n.Value == "cli-pointer"
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cli-channels does not wait for cli-pointer: the cask would name archives that may not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 36 KiB |
@@ -19,7 +19,7 @@
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.4.5",
|
||||
"electron": "44.5.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,11 @@ const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
|
||||
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"';
|
||||
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
|
||||
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
|
||||
// Linux keep the full-canvas artwork the platform templates expect.
|
||||
const WIN_VIEWBOX = 'viewBox="91 91 842 842"';
|
||||
const WIN_GLYPH_SCALE = "scale(2.4)";
|
||||
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
|
||||
const ICNS_TYPES = [
|
||||
["ic04", 16], ["ic05", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512], ["ic10", 1024],
|
||||
@@ -74,8 +79,9 @@ async function main() {
|
||||
for (const [scale, suffix] of TRAY_SCALES) {
|
||||
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
|
||||
}
|
||||
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
|
||||
const icoPngs = [];
|
||||
for (const size of ICO_SIZES) icoPngs.push([size, await at(size)]);
|
||||
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
|
||||
fs.writeFileSync(path.join(ASSETS, "icon.ico"), ico(icoPngs));
|
||||
const icnsPngs = [];
|
||||
for (const [type, size] of ICNS_TYPES) icnsPngs.push([type, await at(size)]);
|
||||
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.4.5
|
||||
version: 44.4.5([email protected])
|
||||
specifier: 44.5.1
|
||||
version: 44.5.1([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected])([email protected])
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@44.4.5:
|
||||
resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==}
|
||||
electron@44.5.1:
|
||||
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@44.4.5([email protected]):
|
||||
electron@44.5.1([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
const crypto = require("node:crypto");
|
||||
const { WebContentsView, session } = require("electron");
|
||||
const { installPaneReload } = require("./reload");
|
||||
const { guestNavigationAllowed, typedAddress } = require("./browserguard");
|
||||
|
||||
// The size a page lays out at while nobody is looking at it: the kernel drives
|
||||
@@ -96,6 +97,7 @@ class BrowserViews {
|
||||
if (allowed(to)) onPopup(to);
|
||||
return { action: "deny" };
|
||||
});
|
||||
installPaneReload(contents);
|
||||
contents.debugger.attach("1.3");
|
||||
contents.debugger.on("message", (_event, method, params, sessionId) => {
|
||||
if (sessionId) return;
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
"use strict";
|
||||
|
||||
// No menu this shell installs carries a reload role, and a page gone blank,
|
||||
// whether its tree unmounted or its renderer died, has no way back but
|
||||
// quitting. The kernel owns every running turn and a loaded page finds it again.
|
||||
// A page gone blank, whether its tree unmounted or its renderer died, has no
|
||||
// way back but quitting. The kernel owns every running turn and a loaded page
|
||||
// finds it again. F5 and Ctrl/Cmd+R belong to the page, so the recovery chord
|
||||
// is the shifted one.
|
||||
const CRASH_RELOADS = 3;
|
||||
const CRASH_WINDOW_MS = 60_000;
|
||||
|
||||
function reloadAction(input, platform = process.platform) {
|
||||
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt) return null;
|
||||
const mod = platform === "darwin" ? input.meta && !input.control : input.control && !input.meta;
|
||||
const latin = typeof input.key === "string" && /^[a-z]$/i.test(input.key);
|
||||
const isR = latin ? input.key.toLowerCase() === "r" : input.code === "KeyR";
|
||||
if (mod && isR) return input.shift ? "hard" : "reload";
|
||||
if (!input.control && !input.meta && !input.shift && input.key === "F5") return "reload";
|
||||
return null;
|
||||
}
|
||||
|
||||
function installPaneReload(contents, platform = process.platform) {
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
const action = reloadAction(input, platform);
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
if (action === "hard") contents.reloadIgnoringCache();
|
||||
else contents.reload();
|
||||
});
|
||||
}
|
||||
|
||||
// A dead renderer's input never reaches before-input-event, so the crash itself
|
||||
// has to trigger the reload, or for a hidden window the next revive before it is
|
||||
// shown. A page that dies on every load stops being reloaded.
|
||||
function installReload(contents, window, { platform = process.platform, now = Date.now } = {}) {
|
||||
const mac = platform === "darwin";
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt || input.shift) return;
|
||||
const mod = mac ? input.meta && !input.control : input.control && !input.meta;
|
||||
const bare = !input.control && !input.meta;
|
||||
const chord = mod && (input.key === "r" || input.key === "R" || input.code === "KeyR");
|
||||
if (!chord && !(bare && input.key === "F5")) return;
|
||||
if (reloadAction(input, platform) !== "hard") return;
|
||||
event.preventDefault();
|
||||
contents.reload();
|
||||
});
|
||||
@@ -40,4 +56,4 @@ function installReload(contents, window, { platform = process.platform, now = Da
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installReload, CRASH_RELOADS, CRASH_WINDOW_MS };
|
||||
module.exports = { installReload, installPaneReload, reloadAction, CRASH_RELOADS, CRASH_WINDOW_MS };
|
||||
@@ -1018,30 +1018,67 @@ function reloadRig(platform = "darwin") {
|
||||
return { state, send, gone, revive };
|
||||
}
|
||||
|
||||
test("the reload key brings back a window whose page stopped drawing", () => {
|
||||
const press = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
|
||||
const cases = [
|
||||
["darwin", { meta: true }, { control: true }],
|
||||
["win32", { control: true }, { meta: true }],
|
||||
["linux", { control: true }, { meta: true }],
|
||||
];
|
||||
for (const [platform, mod, other] of cases) {
|
||||
const reloadPress = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
|
||||
const reloadMods = [
|
||||
["darwin", { meta: true }, { control: true }],
|
||||
["win32", { control: true }, { meta: true }],
|
||||
["linux", { control: true }, { meta: true }],
|
||||
];
|
||||
|
||||
test("reloadAction reads the chord, not the layout", () => {
|
||||
const { reloadAction } = require("../src/reload.js");
|
||||
for (const [platform, mod, other] of reloadMods) {
|
||||
const at = (input) => reloadAction(input, platform);
|
||||
assert.equal(at(reloadPress(mod)), "reload");
|
||||
assert.equal(at(reloadPress({ ...mod, key: "к" })), "reload");
|
||||
assert.equal(at(reloadPress({ key: "F5", code: "F5" })), "reload");
|
||||
assert.equal(at(reloadPress({ ...mod, key: "p", code: "KeyR" })), null, `${platform}: a Dvorak P reloaded`);
|
||||
assert.equal(at(reloadPress({ ...mod, key: "r", code: "KeyP" })), "reload", `${platform}: a Dvorak R did not reload`);
|
||||
assert.equal(at(reloadPress({ ...mod, shift: true })), "hard");
|
||||
assert.equal(at(reloadPress({ ...mod, shift: true, key: "R" })), "hard");
|
||||
for (const input of [
|
||||
reloadPress(), reloadPress(other), reloadPress({ ...mod, alt: true }),
|
||||
reloadPress({ ...mod, type: "keyUp" }), reloadPress({ ...mod, isAutoRepeat: true }),
|
||||
reloadPress({ ...mod, key: "t", code: "KeyT" }), reloadPress({ key: "F5", code: "F5", control: true }),
|
||||
reloadPress({ key: "F5", code: "F5", shift: true }), reloadPress({ shift: true }),
|
||||
]) assert.equal(at(input), null, `${platform}: ${JSON.stringify(input)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("only the hard reload chord reloads the app window; F5 and Ctrl/Cmd+R reach the page", () => {
|
||||
for (const [platform, mod] of reloadMods) {
|
||||
const { state, send } = reloadRig(platform);
|
||||
send(press(mod));
|
||||
send(press({ ...mod, key: "R" }));
|
||||
send(press({ ...mod, key: "к" }));
|
||||
send(press({ key: "F5", code: "F5" }));
|
||||
assert.equal(state.reloads, 4, `${platform}: the reload keys did not reload`);
|
||||
send(press());
|
||||
send(press(other));
|
||||
send(press({ ...mod, shift: true }));
|
||||
send(press({ ...mod, alt: true }));
|
||||
send(press({ ...mod, type: "keyUp" }));
|
||||
send(press({ ...mod, isAutoRepeat: true }));
|
||||
send(press({ ...mod, key: "t", code: "KeyT" }));
|
||||
send(press({ key: "F5", code: "F5", control: true }));
|
||||
assert.equal(state.reloads, 4, `${platform}: something other than a fresh reload press reloaded`);
|
||||
assert.equal(state.prevented, 4);
|
||||
send(reloadPress(mod));
|
||||
send(reloadPress({ key: "F5", code: "F5" }));
|
||||
assert.equal(state.reloads, 0, `${platform}: a plain reload key reloaded the UI`);
|
||||
assert.equal(state.prevented, 0, `${platform}: a plain reload key was swallowed`);
|
||||
send(reloadPress({ ...mod, shift: true, key: "R" }));
|
||||
assert.equal(state.reloads, 1, `${platform}: the recovery chord did not reload`);
|
||||
assert.equal(state.prevented, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("a browser pane reloads on F5 and Ctrl/Cmd+R, ignoring cache on the shifted chord, and swallows nothing else", () => {
|
||||
const { installPaneReload } = require("../src/reload.js");
|
||||
for (const [platform, mod] of reloadMods) {
|
||||
let handler;
|
||||
const calls = { reload: 0, hard: 0, prevented: 0 };
|
||||
const contents = {
|
||||
on: (name, fn) => { if (name === "before-input-event") handler = fn; },
|
||||
reload: () => { calls.reload += 1; },
|
||||
reloadIgnoringCache: () => { calls.hard += 1; },
|
||||
};
|
||||
installPaneReload(contents, platform);
|
||||
const send = (input) => handler({ preventDefault: () => { calls.prevented += 1; } }, input);
|
||||
send(reloadPress(mod));
|
||||
send(reloadPress({ key: "F5", code: "F5" }));
|
||||
assert.deepEqual(calls, { reload: 2, hard: 0, prevented: 2 }, platform);
|
||||
send(reloadPress({ ...mod, shift: true, key: "R" }));
|
||||
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, platform);
|
||||
send(reloadPress({ ...mod, key: "f", code: "KeyF" }));
|
||||
send(reloadPress({ key: "F12", code: "F12" }));
|
||||
send(reloadPress({ ...mod, alt: true }));
|
||||
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, `${platform}: another key was handled`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@lezer/highlight": "^1.2.4",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.18.9",
|
||||
"katex": "^0.18.10",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -53,7 +53,7 @@
|
||||
"jsdom": "^30.1.1",
|
||||
"playwright": "^1.63.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.1"
|
||||
"vite": "^8.3.2",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
Generated
+125
-138
@@ -60,14 +60,14 @@ importers:
|
||||
specifier: ^6.43.13
|
||||
version: 6.43.13
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
katex:
|
||||
specifier: ^0.18.9
|
||||
version: 0.18.9
|
||||
specifier: ^0.18.10
|
||||
version: 0.18.10
|
||||
react:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
@@ -116,7 +116,7 @@ importers:
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1([email protected].0([email protected]))
|
||||
version: 6.1.1([email protected].2([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.1
|
||||
version: 30.1.1
|
||||
@@ -127,11 +127,11 @@ importers:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0([email protected])
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1([email protected])([email protected].0([email protected]))
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3([email protected])([email protected].2([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -453,8 +453,8 @@ packages:
|
||||
'@lezer/[email protected]':
|
||||
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
|
||||
|
||||
'@lezer/[email protected].4':
|
||||
resolution: {integrity: sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==}
|
||||
'@lezer/[email protected].5':
|
||||
resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
|
||||
|
||||
'@lezer/[email protected]':
|
||||
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
||||
@@ -489,101 +489,101 @@ packages:
|
||||
'@marijn/[email protected]':
|
||||
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
|
||||
|
||||
'@oxc-project/[email protected]47.0':
|
||||
resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==}
|
||||
'@oxc-project/[email protected]52.0':
|
||||
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -799,8 +799,8 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].1':
|
||||
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
|
||||
'@vitest/[email protected].3':
|
||||
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -810,8 +810,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].1':
|
||||
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
|
||||
'@vitest/[email protected].3':
|
||||
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
@@ -1051,8 +1051,8 @@ packages:
|
||||
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected].9:
|
||||
resolution: {integrity: sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==}
|
||||
[email protected].10:
|
||||
resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -1396,8 +1396,8 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==}
|
||||
[email protected].12:
|
||||
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -1408,9 +1408,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1418,9 +1415,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
@@ -1511,8 +1505,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -1554,20 +1548,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.1
|
||||
'@vitest/browser-preview': 5.0.1
|
||||
'@vitest/browser-playwright': 5.0.3
|
||||
'@vitest/browser-preview': 5.0.3
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.1
|
||||
'@vitest/coverage-v8': 5.0.1
|
||||
'@vitest/ui': 5.0.1
|
||||
'@vitest/coverage-istanbul': 5.0.3
|
||||
'@vitest/coverage-v8': 5.0.3
|
||||
'@vitest/ui': 5.0.3
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -1621,9 +1615,9 @@ packages:
|
||||
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
why-is-node-running@3.2.1:
|
||||
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
|
||||
engines: {node: '>=20.11'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -1771,7 +1765,7 @@ snapshots:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
@@ -1789,7 +1783,7 @@ snapshots:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
'@lezer/yaml': 1.0.4
|
||||
|
||||
@@ -1798,7 +1792,7 @@ snapshots:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
style-mod: 4.1.4
|
||||
|
||||
@@ -1943,41 +1937,41 @@ snapshots:
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected].4':
|
||||
'@lezer/[email protected].5':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
@@ -1987,79 +1981,79 @@ snapshots:
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@marijn/[email protected]': {}
|
||||
|
||||
'@oxc-project/[email protected]47.0': {}
|
||||
'@oxc-project/[email protected]52.0': {}
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
@@ -2194,21 +2188,21 @@ snapshots:
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
|
||||
'@vitejs/[email protected]([email protected].0([email protected]))':
|
||||
'@vitejs/[email protected]([email protected].2([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.0([email protected])
|
||||
vite: 8.3.2([email protected])
|
||||
|
||||
'@vitest/[email protected].1([email protected].0([email protected]))':
|
||||
'@vitest/[email protected].3([email protected].2([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.1
|
||||
'@vitest/spy': 5.0.3
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.2
|
||||
optionalDependencies:
|
||||
vite: 8.3.0([email protected])
|
||||
vite: 8.3.2([email protected])
|
||||
|
||||
'@vitest/[email protected].1': {}
|
||||
'@vitest/[email protected].3': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -2519,7 +2513,7 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
[email protected].9:
|
||||
[email protected].10:
|
||||
dependencies:
|
||||
commander: 15.0.0
|
||||
|
||||
@@ -3117,26 +3111,26 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].6:
|
||||
[email protected].12:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.147.0
|
||||
'@oxc-project/types': 0.152.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.6
|
||||
'@rolldown/binding-android-arm64': 1.2.6
|
||||
'@rolldown/binding-darwin-arm64': 1.2.6
|
||||
'@rolldown/binding-darwin-x64': 1.2.6
|
||||
'@rolldown/binding-freebsd-x64': 1.2.6
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.6
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.6
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.6
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.6
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.6
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.6
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.6
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.6
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.6
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.6
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.12
|
||||
'@rolldown/binding-android-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-x64': 1.2.12
|
||||
'@rolldown/binding-freebsd-x64': 1.2.12
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.12
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.12
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.12
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.12
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.12
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -3144,14 +3138,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -3279,21 +3269,21 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
[email protected].0([email protected]):
|
||||
[email protected].2([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
rolldown: 1.2.6
|
||||
rolldown: 1.2.12
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected].1([email protected])([email protected].0([email protected])):
|
||||
[email protected].3([email protected])([email protected].2([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.1([email protected].0([email protected]))
|
||||
'@vitest/mocker': 5.0.3([email protected].2([email protected]))
|
||||
chai: 6.2.2
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -3304,8 +3294,8 @@ snapshots:
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.0([email protected])
|
||||
why-is-node-running: 2.3.0
|
||||
vite: 8.3.2([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
jsdom: 30.1.1
|
||||
transitivePeerDependencies:
|
||||
@@ -3339,10 +3329,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
why-is-node-running@3.2.1: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// refused while a turn runs, so it never stops work.
|
||||
{ id: "session.close", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.copy-info", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
|
||||
{ id: "session.delete", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
|
||||
@@ -113,6 +114,15 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "prompt.adopt", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "prompt.discard", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
// ── Committing what is staged ────────────────────────────────────────────
|
||||
// Drafting reads the index and asks the model; only confirm records a commit,
|
||||
// and only locally.
|
||||
{ id: "commit.draft", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "commit.edit", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "commit.acknowledge", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "commit.confirm", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "commit.close", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
// ── What is waiting to be sent ───────────────────────────────────────────
|
||||
{ id: "queue.edit", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
// Giving a session a name of your own. The sidebar's tree and the pane tabs
|
||||
@@ -389,6 +399,11 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
|
||||
// Usage changes what report is read, not canonical session state.
|
||||
{ id: "usage.range.start", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "usage.range.end", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "usage.range.month", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
// ── How the window looks ─────────────────────────────────────────────────
|
||||
// Named for what a person is doing, not for the control they reached for.
|
||||
// The interface scale has a row of presets and a slider beside it, and both
|
||||
@@ -467,6 +482,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// Read by the running turn at its next round, so no rebuild stands between the
|
||||
// click and the canonical change.
|
||||
// The kernel announces the change on the stream, so no rebuild stands between
|
||||
// the click and the canonical change.
|
||||
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -16,4 +16,16 @@ export const FOLD_WHY: Record<string, string> = {
|
||||
savings_below_minimum: "固定前缀已占满检查点上限,这次折叠省下的空间太少,没有采用",
|
||||
fixed_prefix_above_trigger: "无法折叠的固定部分本身已超过压缩阈值",
|
||||
fold_empty_after_hooks: "扩展钩子把待折叠的内容清空了",
|
||||
cancelled: "压缩被取消了",
|
||||
unclassified: "压缩失败,原因未归类;详情见日志",
|
||||
busy: "有一轮对话或会话切换正在进行,请稍后再试",
|
||||
input_unchanged: "上下文自上次整理后没有变化",
|
||||
no_new_closed_prefix: "自上个检查点以来没有已结束的内容",
|
||||
fold_below_economics: "新增内容太少,不值得再生成一次摘要",
|
||||
active_turn_boundary: "正在进行的这一轮需要先结束",
|
||||
no_foldable_region: "没有可折叠的内容",
|
||||
};
|
||||
|
||||
// The reason a /compact notice carries as its detail; an empty detail is the
|
||||
// kernel's decline with no class.
|
||||
export const NO_CODE_WHY = "没有值得折叠的内容";
|
||||
@@ -546,7 +546,9 @@ export const EN: Record<string, string> = {
|
||||
"编辑": "Edit",
|
||||
"测试中…": "Testing…",
|
||||
"测试连接": "Test it",
|
||||
"没保存成功": "Not saved",
|
||||
"保存失败": "Save failed",
|
||||
"已保存,尚未生效": "Saved, not applied yet",
|
||||
"刷新模型目录失败": "Could not refresh the model catalog",
|
||||
"重新问一次有哪些模型": "Ask for the model list again",
|
||||
"刷新模型目录": "Refresh model catalog",
|
||||
"正在刷新…": "Refreshing…",
|
||||
@@ -785,6 +787,7 @@ export const EN: Record<string, string> = {
|
||||
"打开主题目录": "Open themes folder",
|
||||
"阅读": "Read",
|
||||
"设置需要一个打开的会话。请先在左栏添加一个文件夹。": "Settings need an open session. Add a folder in the sidebar first.",
|
||||
"反馈需要一个打开的会话。请先在左栏添加一个文件夹。": "Feedback needs an open session. Add a folder in the sidebar first.",
|
||||
"{host} 需要登录": "{host} asks you to sign in",
|
||||
"代理 {host} 需要登录": "The proxy {host} asks you to sign in",
|
||||
"用户名": "Username",
|
||||
@@ -809,6 +812,7 @@ export const EN: Record<string, string> = {
|
||||
"已导入「{name}」。": "Imported “{name}”.",
|
||||
"立即使用": "Use now",
|
||||
"未读取:{names}": "Not read: {names}",
|
||||
"请单独选择一个 .zip,或选择不含压缩包的 theme.json 和图片。": "Choose a single .zip on its own, or select a theme folder’s theme.json and images without a ZIP.",
|
||||
"尚未安装主题。选择一个 .zip,或同时选中主题文件夹里的 theme.json 和图片。": "No themes installed. Choose a .zip, or select a theme folder’s theme.json and images together.",
|
||||
"选择一个 .zip,或同时选中主题文件夹里的 theme.json 和图片。": "Choose a .zip, or select a theme folder’s theme.json and images together.",
|
||||
|
||||
@@ -833,6 +837,7 @@ export const EN: Record<string, string> = {
|
||||
"已关闭": "Disabled",
|
||||
"未连接": "Not connected",
|
||||
"待命 · 首次调用时启动": "Standby · starts on first call",
|
||||
"待授权 · 来自项目声明": "Awaiting approval · declared by the project",
|
||||
"本地构建": "Local build",
|
||||
"只在这台机器上": "This machine only",
|
||||
"这个项目": "This project",
|
||||
@@ -1052,6 +1057,7 @@ export const EN: Record<string, string> = {
|
||||
"兼顾响应速度与可靠性,适合大多数任务": "Balance speed and reliability for most tasks",
|
||||
"投入更多时间分析复杂上下文与执行方案": "Spend more time analyzing complex context and execution plans",
|
||||
"用于最复杂的问题,等待时间与消耗最高": "For the hardest problems, with the longest wait and highest usage",
|
||||
"思考仍开启并计费,该模型无法关闭思考": "Thinking stays on and is billed; this model cannot turn it off",
|
||||
"仅显示当前模型实际支持的档位。": "Only levels actually supported by this model are shown.",
|
||||
"按模型生效": "Per model",
|
||||
"查看上下文与压缩": "View context and compaction",
|
||||
@@ -1118,6 +1124,11 @@ export const EN: Record<string, string> = {
|
||||
"读取": "Read",
|
||||
"本地工作区": "Local workspace",
|
||||
"暂停执行": "Pause run",
|
||||
"复制会话信息": "Copy session information",
|
||||
"会话 ID": "Session ID",
|
||||
"会话上下文路径": "Session context path",
|
||||
"任务路径": "Task path",
|
||||
"任务日志": "Task log",
|
||||
"导出会话": "Export session",
|
||||
"取消置顶": "Unpin",
|
||||
"置顶会话": "Pin session",
|
||||
@@ -1212,6 +1223,64 @@ export const EN: Record<string, string> = {
|
||||
"The model returned nothing; try again",
|
||||
"优化请求格式不正确":
|
||||
"The refine request was malformed",
|
||||
"提交…":
|
||||
"Commit…",
|
||||
"起草提交说明":
|
||||
"Draft commit message",
|
||||
"读取暂存区,起草提交说明…":
|
||||
"Reading the staged changes and drafting a message…",
|
||||
"重新起草":
|
||||
"Redraft",
|
||||
"提交说明":
|
||||
"Commit message",
|
||||
"暂存的文件":
|
||||
"Staged files",
|
||||
"{n} 个文件已暂存":
|
||||
"{n} files staged",
|
||||
"可能含有密钥:{files}":
|
||||
"May contain secrets: {files}",
|
||||
"新增的内容里有形似密钥的值":
|
||||
"Added lines contain values shaped like credentials",
|
||||
"我确认这些内容可以提交":
|
||||
"I confirm these can be committed",
|
||||
"提交到本地":
|
||||
"Commit locally",
|
||||
"只在本地提交,不会推送":
|
||||
"Recorded locally only; nothing is pushed",
|
||||
"已提交 {hash}:{subject}":
|
||||
"Committed {hash}: {subject}",
|
||||
"改过暂存区后请重新起草":
|
||||
"Draft again after changing what is staged",
|
||||
"暂存区内容过长,只按前面一部分起草":
|
||||
"The staged diff is long; the draft reads only its first part",
|
||||
"这个工作区不是 git 仓库,无法提交":
|
||||
"This workspace is not a git repository, so there is nothing to commit",
|
||||
"暂存区是空的,先用 git add 暂存要提交的文件":
|
||||
"Nothing is staged; stage the files to commit with git add first",
|
||||
"暂存区在你确认之后又变了,请重新起草":
|
||||
"The staged changes changed after you reviewed them; draft again",
|
||||
"暂存的内容里有疑似密钥的文件,需要你确认后才能提交":
|
||||
"The staged changes include likely secrets; confirm them before committing",
|
||||
"提交说明是空的":
|
||||
"The commit message is empty",
|
||||
"提交说明含有无法记录的字符":
|
||||
"The commit message contains characters git cannot record",
|
||||
"提交说明超过 {max_bytes} 字节":
|
||||
"The commit message is over {max_bytes} bytes",
|
||||
"git 没有配置提交者姓名和邮箱(user.name、user.email)":
|
||||
"git has no author name and email configured (user.name, user.email)",
|
||||
"git 没能记录这次提交":
|
||||
"git could not record the commit",
|
||||
"当前会话没有可用的模型,无法起草提交说明":
|
||||
"This session has no model to draft a commit message with",
|
||||
"起草提交说明超时,请重试":
|
||||
"Drafting the commit message timed out; try again",
|
||||
"模型没有给出提交说明,请重试":
|
||||
"The model returned no commit message; try again",
|
||||
"读取暂存区失败":
|
||||
"Reading the staged changes failed",
|
||||
"提交请求格式不正确":
|
||||
"The commit request was malformed",
|
||||
"新增工具":
|
||||
"New tools",
|
||||
"agent 可以调用,每次调用照常经过权限确认":
|
||||
@@ -1230,6 +1299,7 @@ export const EN: Record<string, string> = {
|
||||
"任务已按设置暂停:模型卡在重复输出同一段文字": "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:",
|
||||
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
|
||||
"上下文已用到压缩阈值的 {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.",
|
||||
};
|
||||
@@ -21,6 +21,9 @@ export const EN_BACKUP: Record<string, string> = {
|
||||
"备注(可选)": "Label (optional)",
|
||||
"例如:公司笔记本": "e.g. work laptop",
|
||||
"加密口令(至少 {n} 个字符)": "Passphrase (at least {n} characters)",
|
||||
"至少选择一项备份内容": "Tick at least one thing to back up",
|
||||
"口令还差 {n} 个字符": "Passphrase too short: {n} more needed",
|
||||
"请再输一次口令": "Repeat the passphrase to confirm it",
|
||||
"再输一次": "Repeat it",
|
||||
"两次输入的口令不一致": "The two passphrases differ",
|
||||
"正在加密上传…": "Encrypting and uploading…",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
export const EN_COMPOSER: Record<string, string> = {
|
||||
"↓ 回到最新": "↓ Back to latest",
|
||||
"不思考": "No reasoning",
|
||||
"最轻量": "Minimal",
|
||||
"比快速档更轻;GLM-5.2 会跳过思考": "Lighter than Fast; GLM-5.2 skips thinking",
|
||||
"超深入": "Extra deep",
|
||||
"不做推理直接作答,响应最快,适合简单问答": "Answers without reasoning; fastest, for simple questions",
|
||||
"比深入投入更多推理,适合困难的多步问题": "More reasoning than Deep, for hard multi-step problems",
|
||||
|
||||
@@ -188,12 +188,14 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
|
||||
"名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。": "The name “{name}” cannot hold a key: the credential slot is derived from it, and a slot may not start with a digit. Use a name starting with a letter — the key itself is fine.",
|
||||
"这个内核没有带界面,只提供接口": "This kernel serves no interface, only its API",
|
||||
"当前模型还没有 API key,请先在设置里添加": "The current model has no API key yet. Add one in Settings first.",
|
||||
"请填写 API key": "An API key is required",
|
||||
"该 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 contains a character combination that cannot be stored safely — copy it again",
|
||||
"该 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",
|
||||
@@ -240,6 +242,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"无法解析该会话路径": "That conversation path does not resolve",
|
||||
"打不开这个会话": "Could not open that conversation",
|
||||
"该路径位于会话目录之外": "That path is outside the conversations directory",
|
||||
"没有这个会话": "No such conversation",
|
||||
"该地址不接受此种请求方式": "That address does not accept this request method",
|
||||
"请求体必须是 application/json": "The request body must be application/json",
|
||||
"这台服务器未开放权限编辑": "This server does not open permission editing",
|
||||
@@ -330,6 +333,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹": "The target is outside the workspace write scope. This is the file tools' write scope, not an OS sandbox; to allow the write, add the target folder under Settings → Sandbox → Also writable",
|
||||
"无法读取工作区文件列表": "The workspace file list could not be read",
|
||||
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
|
||||
"不支持这个币种,只能选自动、CNY 或 USD:{detail}": "That currency is not supported; choose Auto, CNY or USD: {detail}",
|
||||
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
|
||||
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
|
||||
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
|
||||
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
|
||||
|
||||
@@ -462,6 +462,18 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"固定前缀已占满检查点上限,这次折叠省下的空间太少,没有采用": "The fixed prefix fills the checkpoint limit and this fold saved too little, so it was not used",
|
||||
"无法折叠的固定部分本身已超过压缩阈值": "The part that cannot be folded is already over the compaction threshold",
|
||||
"扩展钩子把待折叠的内容清空了": "An extension hook emptied the content to fold",
|
||||
"压缩被取消了": "The compaction was cancelled",
|
||||
"压缩失败,原因未归类;详情见日志": "The compaction failed for a reason the host did not classify; see the log",
|
||||
"有一轮对话或会话切换正在进行,请稍后再试": "A turn or a session change is in progress; try again shortly",
|
||||
"上下文自上次整理后没有变化": "The context has not changed since it was last tidied",
|
||||
"自上个检查点以来没有已结束的内容": "Nothing has closed since the last checkpoint",
|
||||
"新增内容太少,不值得再生成一次摘要": "Too little has been added to pay for another summary",
|
||||
"正在进行的这一轮需要先结束": "The turn in flight has to finish first",
|
||||
"没有可折叠的内容": "No foldable region remains",
|
||||
"没有值得折叠的内容": "Nothing left worth folding",
|
||||
"已压缩": "Compacted",
|
||||
"无需压缩:{why}": "Nothing to compact: {why}",
|
||||
"压缩失败:{why}": "Compaction failed: {why}",
|
||||
"本段的 {n} 处改动均已写入简报": "All {n} changes made here are in the digest",
|
||||
"{kept}/{required} 处改动写进了简报": "{kept}/{required} changes are in the digest",
|
||||
"另有 {n} 处仅保留索引地址,需通过 recall 取回原文": "{n} more are index entries only; recall fetches the original",
|
||||
@@ -678,6 +690,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"检测中…": "Checking…",
|
||||
"验证连接并读取": "Check connection and read models",
|
||||
"连接可用 · 找到 {n} 个模型": "Connection works · {n} models found",
|
||||
"接口地址已补全为 {url}": "Address completed to {url}",
|
||||
"刷新模型目录时,模型列表只在 {url} 下响应。聊天若能正常使用就不用改;可选:把接口地址改成它。": "The model list only answers at {url}. If chat already works, no change is needed; optionally set the address to it.",
|
||||
"启用的模型": "Enabled models",
|
||||
"目录只用于发现,不是白名单。直接输入服务商给出的原始模型 ID 即可。": "The catalogue is for discovery, not a whitelist. You can enter any exact model ID supplied by the service.",
|
||||
"阈值需至少为 1,000,并小于模型上下文窗口。": "The threshold must be at least 1,000 and below the model context window.",
|
||||
|
||||
@@ -9,7 +9,9 @@ export const EN_STORAGE: Record<string, string> = {
|
||||
"选择旧版数据目录…": "Choose older data folder…",
|
||||
"正在扫描…": "Scanning…",
|
||||
"已找回 {n} 个会话。": "Recovered {n} sessions.",
|
||||
"没有发现尚未导入的旧会话。": "No sessions remain to be imported.",
|
||||
"没有发现尚未导入的旧会话。": "Nothing new to import.",
|
||||
"这个文件夹里没有找到旧版会话。请选择 Reasonix 的数据文件夹(里面有 sessions、projects 或 desktop-sessions-v5)。":
|
||||
"No older sessions were found in this folder. Pick the Reasonix data folder (the one holding sessions, projects or desktop-sessions-v5).",
|
||||
"有 {n} 项无法读取。": "{n} items could not be read.",
|
||||
"未能扫描这个文件夹。请确认它是旧版 Reasonix 的数据目录。": "This folder could not be scanned. Make sure it is an older Reasonix data folder.",
|
||||
存储: "Storage",
|
||||
|
||||
@@ -22,11 +22,30 @@ export const EN_USAGE: Record<string, string> = {
|
||||
"以检查点版本覆盖": "Overwrite with the checkpoint's",
|
||||
"保留当前版本": "Keep the current one",
|
||||
"用量": "Usage",
|
||||
"费用显示币种": "Cost display currency",
|
||||
"只决定费用以哪种货币显示,不改动各来源的价目表。「自动」跟随账户余额的币种,没有余额信息时按价目表的币种显示。":
|
||||
"Only decides which currency costs are shown in; each source's price list is untouched. Auto follows the account balance's currency, and falls back to the price list's currency when there is no balance.",
|
||||
"显示币种": "Display currency",
|
||||
"当前会话的费用、余额与用量页立即按所选币种显示,无需重启。": "The session's cost, the balance and the usage page switch to the chosen currency at once, no restart.",
|
||||
"人民币 CNY": "Chinese yuan (CNY)",
|
||||
"美元 USD": "US dollar (USD)",
|
||||
"无法读取币种设置。": "Could not read the currency setting.",
|
||||
"用量与成本": "Usage & cost",
|
||||
"本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。":
|
||||
"Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.",
|
||||
"正在读取记录…": "Reading the records…",
|
||||
"时间范围": "Time range",
|
||||
"自然月": "Calendar month",
|
||||
"开始日期": "Start date",
|
||||
"结束日期": "End date",
|
||||
"应用": "Apply",
|
||||
"选择月份": "Choose month",
|
||||
"每日明细": "Daily details",
|
||||
"精确到每天": "Exact totals for each day",
|
||||
"左右滚动查看": "Scroll sideways for all columns",
|
||||
"日期": "Date",
|
||||
"支出": "Spend",
|
||||
"请求": "Requests",
|
||||
"7 天": "7 days",
|
||||
"30 天": "30 days",
|
||||
"全部": "All",
|
||||
@@ -54,6 +73,7 @@ export const EN_USAGE: Record<string, string> = {
|
||||
"另有 {n} 天有用量但没有成本记录 —— 不是那几天没花费,是成本字段后来才开始持久化。": "{n} more days have usage but no cost recorded — not free days; the cost field started being persisted later.",
|
||||
"按模型": "By model",
|
||||
"按来源": "By source",
|
||||
"未知来源": "Unknown source",
|
||||
"尚无 key?前往获取": "No key yet? Get one",
|
||||
"打开 {name} 控制台": "Open the {name} console",
|
||||
"无法打开浏览器,请手动访问 {at}": "Could not open a browser — visit {at} yourself",
|
||||
|
||||
@@ -18,7 +18,7 @@ export const PROVIDER_EDIT_DISABLED = "provider.editing_disabled";
|
||||
// A paired device answers this for every account route, votes included.
|
||||
export const ACCOUNT_SIGNIN_DISABLED = "account.signin_disabled";
|
||||
// The write landed and only applying it to the open conversation did not.
|
||||
export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_running", "provider.saved_model_unlisted", "runtime.rebuild_failed"];
|
||||
export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_running", "provider.saved_model_unlisted", "runtime.rebuild_failed", "runtime.saved_while_running"];
|
||||
|
||||
const SAID: Record<string, string> = {
|
||||
"shell.destructive_target": "递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径",
|
||||
@@ -78,6 +78,7 @@ const SAID: Record<string, string> = {
|
||||
"session.bad_path": "无法解析该会话路径",
|
||||
"session.open_failed": "打不开这个会话",
|
||||
"session.outside_dir": "该路径位于会话目录之外",
|
||||
"session.unknown": "没有这个会话",
|
||||
"request.method_not_allowed": "该地址不接受此种请求方式",
|
||||
"request.bad_content_type": "请求体必须是 application/json",
|
||||
"permissions.editing_disabled": "这台服务器未开放权限编辑",
|
||||
@@ -136,6 +137,7 @@ const SAID: Record<string, string> = {
|
||||
"provider.no_current_model": "当前没有正在使用的模型,无法记录其窗口大小",
|
||||
"context.window_after_this_turn": "窗口大小已记录,将在本轮结束后生效",
|
||||
"provider.saved_while_running": "已保存。当前对话还有未结束的工作(正在运行、等待你回答或有后台任务),仍按原设置进行;结束后再保存一次即可生效",
|
||||
"runtime.saved_while_running": "已保存。当前对话还有未结束的工作(正在运行、等待你回答或有后台任务),仍按原设置进行;结束后再保存一次即可生效",
|
||||
"provider.saved_model_unlisted": "已保存。当前对话使用的模型已不在该来源的列表中,切换模型后才会生效",
|
||||
"provider.extra_body_null": "额外设置中的「{path}」不能为空值(null)",
|
||||
"provider.no_websearch_wire": "该协议不支持由端点自行搜索",
|
||||
@@ -169,12 +171,14 @@ const SAID: Record<string, string> = {
|
||||
"picker.failed": "打不开文件夹选择框:{error}",
|
||||
"provider.bad_key_slot": "名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。",
|
||||
"page.not_built": "这个内核没有带界面,只提供接口",
|
||||
"provider.key_missing": "当前模型还没有 API key,请先在设置里添加",
|
||||
"provider.key_required": "请填写 API key",
|
||||
"provider.key_too_large": "该 key 长度异常,可能粘贴了错误内容",
|
||||
"provider.setup_done": "已连接,无需重复配置",
|
||||
"provider.setup_failed": "远端配置未完成,请稍后重试",
|
||||
"provider.unknown": "没有这个模型连接",
|
||||
"provider.no_key_slot": "该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env",
|
||||
"provider.key_unstorable": "该 key 含有无法安全保存的字符组合,请重新复制",
|
||||
"provider.key_invalid": "该 key 超长或含换行,请重新复制",
|
||||
"provider.credentials_changed": "已保存的密钥在此期间被改动,请重新打开配置再试",
|
||||
"provider.activation_failed": "密钥已保存,但连接尚未生效,请重试",
|
||||
@@ -214,6 +218,21 @@ const SAID: Record<string, string> = {
|
||||
"prompt_refine.failed": "优化失败,请重试",
|
||||
"prompt_refine.bad_request": "优化请求格式不正确",
|
||||
|
||||
"commit.no_repository": "这个工作区不是 git 仓库,无法提交",
|
||||
"commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件",
|
||||
"commit.staged_changed": "暂存区在你确认之后又变了,请重新起草",
|
||||
"commit.secrets_staged": "暂存的内容里有疑似密钥的文件,需要你确认后才能提交",
|
||||
"commit.empty_message": "提交说明是空的",
|
||||
"commit.message_invalid": "提交说明含有无法记录的字符",
|
||||
"commit.message_too_long": "提交说明超过 {max_bytes} 字节",
|
||||
"commit.identity_missing": "git 没有配置提交者姓名和邮箱(user.name、user.email)",
|
||||
"commit.failed": "git 没能记录这次提交",
|
||||
"commit.no_model": "当前会话没有可用的模型,无法起草提交说明",
|
||||
"commit.timeout": "起草提交说明超时,请重试",
|
||||
"commit.no_answer": "模型没有给出提交说明,请重试",
|
||||
"commit.git_failed": "读取暂存区失败",
|
||||
"commit.bad_request": "提交请求格式不正确",
|
||||
|
||||
// ── 会话 ─────────────────────────────────────────────────────────
|
||||
"session.disabled": "这台服务器已关闭会话切换",
|
||||
"session.pending_cleanup": "该会话正在清理,请稍后再打开",
|
||||
@@ -289,6 +308,8 @@ const SAID: Record<string, string> = {
|
||||
"compaction.no_soft_limit": "本次请求未包含阈值,未做任何修改",
|
||||
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
|
||||
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
|
||||
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
|
||||
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
|
||||
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
|
||||
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
|
||||
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
|
||||
|
||||
@@ -21,5 +21,9 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
|
||||
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
|
||||
context_budget: "上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。",
|
||||
display_currency: "费用显示币种已设为 {mode}",
|
||||
compacted: "已压缩",
|
||||
compact_declined: "无需压缩:{why}",
|
||||
compact_failed: "压缩失败:{why}",
|
||||
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
|
||||
};
|
||||
@@ -84,6 +84,14 @@ export interface BrowserToolsSettings {
|
||||
|
||||
// When a run reads as no longer moving. Only the user file holds it: a
|
||||
// project file cannot pause the user's runs.
|
||||
export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
|
||||
|
||||
// Which currency costs are shown in; the user file holds it.
|
||||
export interface DisplayCurrencySettings {
|
||||
mode: DisplayCurrencyMode;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProgressWatchSettings {
|
||||
pause: boolean;
|
||||
rounds: number;
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface HubPort extends SharePort {
|
||||
archiveSession(path: string, archived: boolean): Promise<void>;
|
||||
renameSession(path: string, title: string): Promise<void>;
|
||||
exportSession(path: string): Promise<{ name: string; content: string }>;
|
||||
importLegacySessions(path: string, workspace: string): Promise<{ summary: string; imported: number; warnings: number }>;
|
||||
importLegacySessions(path: string, workspace: string): Promise<{ summary: string; imported: number; warnings: number; recognised: boolean }>;
|
||||
// The host book with each link's state, or null where this kernel refuses
|
||||
// remote panes outright — a page served to a browser, rather than the window.
|
||||
// Null is what lets the sidebar leave the whole section out instead of
|
||||
@@ -247,7 +247,7 @@ export class SseHub implements HubPort {
|
||||
}
|
||||
|
||||
importLegacySessions(path: string, workspace: string) {
|
||||
return this.post<{ summary: string; imported: number; warnings: number }>("/tree/sessions/import-legacy", { path, workspace });
|
||||
return this.post<{ summary: string; imported: number; warnings: number; recognised: boolean }>("/tree/sessions/import-legacy", { path, workspace });
|
||||
}
|
||||
|
||||
async remoteHosts() {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface McpEntry {
|
||||
// differ until the next one.
|
||||
alwaysLoad?: boolean;
|
||||
inSchema?: boolean;
|
||||
launch?: string;
|
||||
}
|
||||
|
||||
// always puts a server's tools in every request; deferred reaches them through
|
||||
@@ -87,6 +88,11 @@ export interface McpDraftServer {
|
||||
url?: string;
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
displayUrl?: string;
|
||||
displayEnv?: Record<string, string>;
|
||||
displayHeaders?: Record<string, string>;
|
||||
displayCommand?: string;
|
||||
displayArgs?: string[];
|
||||
}
|
||||
|
||||
// What the confirmation card must show: shell is the command that will run,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { 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, UsageQuery } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockFeedback } from "./mock_feedback";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
@@ -122,8 +122,8 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
|
||||
private mem: MemoryEntry[] = MEMORIES.map((m) => ({ ...m }));
|
||||
|
||||
async usage(days: number): Promise<UsageReport> {
|
||||
return mockUsage(days);
|
||||
async usage(query: number | UsageQuery): Promise<UsageReport> {
|
||||
return mockUsage(query);
|
||||
}
|
||||
|
||||
async memories(): Promise<MemoryCatalog> {
|
||||
@@ -775,7 +775,7 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
return mockStoragePlan(root, dir);
|
||||
}
|
||||
|
||||
async setModel(ref: string) {
|
||||
async setModel(ref: string, _asDefault?: boolean) {
|
||||
this.state.modelRef = ref;
|
||||
this.state.label = ref.split("/").pop() ?? ref;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { ProgressWatchSettings } from "./boundary";
|
||||
import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings } from "./boundary";
|
||||
import { MockShell } from "./mock_shell";
|
||||
|
||||
// The boundary half of the fixture: what the agent is refused outright, and how
|
||||
@@ -104,6 +104,17 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.browser };
|
||||
}
|
||||
|
||||
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async displayCurrency(): Promise<DisplayCurrencySettings> {
|
||||
return { ...this.currency };
|
||||
}
|
||||
|
||||
async saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings> {
|
||||
this.currency = { ...this.currency, mode };
|
||||
return { ...this.currency };
|
||||
}
|
||||
|
||||
private watch: ProgressWatchSettings = {
|
||||
pause: false, rounds: 20, tokenMultiple: 8, defaultRounds: 20, defaultTokenMultiple: 8,
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MockBackup } from "./mock_backup";
|
||||
import type { CommitProposal, CommitRequest, CommitResult } from "./workspace";
|
||||
|
||||
export class MockCommit extends MockBackup {
|
||||
async proposeCommit(): Promise<CommitProposal> {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
return {
|
||||
message: "fix(provider): back off retries on 429 and drop credentials from logs\n\nHonour Retry-After before the next attempt and stop printing the key.",
|
||||
fingerprint: "mock",
|
||||
files: [
|
||||
{ path: "internal/provider/retry.go", status: "M", insertions: 14, deletions: 3 },
|
||||
{ path: "internal/config/credentials.go", status: "M", insertions: 6, deletions: 2 },
|
||||
],
|
||||
truncated: false,
|
||||
contentSecrets: false,
|
||||
};
|
||||
}
|
||||
|
||||
async commitStaged(req: CommitRequest): Promise<CommitResult> {
|
||||
return { hash: "0123456789abcdef0123456789abcdef01234567", subject: req.message.split("\n")[0] };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockBackup } from "./mock_backup";
|
||||
import { MockCommit } from "./mock_commit";
|
||||
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 };
|
||||
@@ -59,7 +59,7 @@ 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 {
|
||||
export class MockFeedback extends MockCommit {
|
||||
private filed: FeedbackItem[] = [];
|
||||
private name = "";
|
||||
private rows: FeedbackItem[] = SEEDED.map((i) => ({ ...i, replies: [...i.replies] }));
|
||||
|
||||
@@ -221,7 +221,7 @@ export class MockHub implements HubPort {
|
||||
}
|
||||
|
||||
importLegacySessions(_path: string, _workspace: string) {
|
||||
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0 });
|
||||
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0, recognised: true });
|
||||
}
|
||||
|
||||
hostCapabilities() {
|
||||
|
||||
@@ -111,6 +111,15 @@ export class MockProvider extends MockBoundary {
|
||||
if (request.model.includes("missing")) {
|
||||
return { model: request.model, status: "unavailable", reason: "not_found" };
|
||||
}
|
||||
if (request.model.includes("refused")) {
|
||||
return {
|
||||
model: request.model,
|
||||
status: "unknown",
|
||||
reason: "rejected",
|
||||
httpStatus: 400,
|
||||
detail: `registry.ollama.ai/library/${request.model} does not support tools`,
|
||||
};
|
||||
}
|
||||
return { model: request.model, status: "available" };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mockUsage } from "./mock_usage";
|
||||
|
||||
describe("mock usage windows", () => {
|
||||
it("returns the requested explicit calendar range", () => {
|
||||
const report = mockUsage({ from: "2026-08-03", to: "2026-08-05" });
|
||||
|
||||
expect(report.from).toBe("2026-08-03");
|
||||
expect(report.to).toBe("2026-08-05");
|
||||
expect(report.daily.map((day) => day.day)).toEqual(["2026-08-03", "2026-08-04", "2026-08-05"]);
|
||||
});
|
||||
|
||||
it("rejects a range longer than the kernel accepts", () => {
|
||||
expect(() => mockUsage({ from: "2025-01-01", to: "2026-01-01" })).toThrow("range must be between 1 and 365 days");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,44 @@
|
||||
import type { UsageReport } from "./port";
|
||||
import type { UsageQuery, UsageReport } from "./port";
|
||||
import { USAGE_PRICED, USAGE_TOKENS } from "./mock_memory";
|
||||
import { DEFAULT_USAGE_DAYS } from "./usage";
|
||||
|
||||
const DAY_MS = 864e5;
|
||||
|
||||
function parseDay(value: string): number {
|
||||
const parsed = Date.parse(`${value}T00:00:00Z`);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(parsed) || new Date(parsed).toISOString().slice(0, 10) !== value) {
|
||||
throw new Error("range dates must be YYYY-MM-DD");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function explicitRange(query: Extract<UsageQuery, { from: string }>) {
|
||||
const from = parseDay(query.from);
|
||||
const to = parseDay(query.to);
|
||||
const days = Math.round((to - from) / DAY_MS) + 1;
|
||||
if (days < 1 || days > 365) throw new Error("range must be between 1 and 365 days");
|
||||
return { from, days };
|
||||
}
|
||||
|
||||
// A fortnight with the shape a real one has: a couple of heavy days, a
|
||||
// quiet stretch, and an early span the recorder priced before cost was
|
||||
// persisted — those days carry tokens and no cost, which the panel must not
|
||||
// render as free.
|
||||
export function mockUsage(days: number): UsageReport {
|
||||
export function mockUsage(query: number | UsageQuery): UsageReport {
|
||||
const shape = USAGE_TOKENS;
|
||||
const priced = USAGE_PRICED;
|
||||
const explicit = typeof query === "number" || "days" in query ? null : explicitRange(query);
|
||||
const days = explicit?.days ?? (typeof query === "number" ? query : "days" in query ? query.days : DEFAULT_USAGE_DAYS);
|
||||
if (days < 1 || days > 365) throw new Error("days must be between 1 and 365");
|
||||
const count = explicit ? days : Math.min(days, shape.length);
|
||||
// Index against the full arrays, not the slice: taking 7 of 13 days shifted
|
||||
// both the dates and the cost column by six.
|
||||
const from = shape.length - Math.min(days, shape.length);
|
||||
const daily = shape.slice(from).map((total, i) => {
|
||||
const day = from + i;
|
||||
const at = new Date(Date.now() - (shape.length - 1 - day) * 864e5).toISOString().slice(0, 10);
|
||||
const amount = priced[day];
|
||||
const first = explicit ? 0 : shape.length - count;
|
||||
const daily = Array.from({ length: count }, (_, i) => {
|
||||
const fixture = explicit ? i % shape.length : first + i;
|
||||
const total = shape[fixture];
|
||||
const at = (explicit ? new Date(explicit.from + i * DAY_MS) : new Date(Date.now() - (count - 1 - i) * DAY_MS)).toISOString().slice(0, 10);
|
||||
const amount = priced[fixture];
|
||||
return {
|
||||
day: at, total,
|
||||
byModel: (total ? { "deepseek/deepseek-flash": total } : {}) as Record<string, number>,
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface ModelEntry {
|
||||
answers?: "chat" | "decision";
|
||||
efforts?: string[];
|
||||
effort?: string;
|
||||
// Even the lowest effort still reasons and is billed.
|
||||
forcesThinking?: boolean;
|
||||
contextWindow?: number;
|
||||
price?: ModelPrice;
|
||||
// The credential this route spends; pairs with vendor to name the account.
|
||||
|
||||
@@ -11,16 +11,17 @@ export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adju
|
||||
import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook";
|
||||
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp";
|
||||
import type { MemoryCatalog, MemoryEdit, MemoryEntry } from "./memory";
|
||||
import type { UsageReport } from "./usage";
|
||||
import type { UsageQuery, UsageReport } from "./usage";
|
||||
export { DEFAULT_USAGE_DAYS } from "./usage";
|
||||
export type { MemoryEdit } from "./memory";
|
||||
export type { Money, UsageDay, UsageModel, UsageProvider, UsageReport } from "./usage";
|
||||
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments } from "./model";
|
||||
import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry } from "./skill";
|
||||
import type { UpdateProgress, VersionEntry, VersionHub } from "./version";
|
||||
import type { ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace";
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace";
|
||||
|
||||
// The port is one contract; its subjects each keep their own file, the way the
|
||||
// wire and the layers below already do. This is where a reader still finds
|
||||
@@ -32,13 +33,13 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
|
||||
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
|
||||
RewindResult, RewindScope, RoleAssignments, ScopeLayer, SessionEntry, SessionStatus,
|
||||
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
|
||||
VersionHub, WalletLine, WalletReading, ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
|
||||
VersionHub, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
|
||||
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import type { Appearance, ThemeImport, ThemePack } from "./look";
|
||||
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
|
||||
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
|
||||
import type { 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";
|
||||
@@ -218,6 +219,7 @@ export interface AgentPort {
|
||||
dryRunHook(h: HookEntry): Promise<HookDryRun>;
|
||||
memories(): Promise<MemoryCatalog>;
|
||||
usage(days: number, source?: string): Promise<UsageReport>;
|
||||
usage(query: UsageQuery): Promise<UsageReport>;
|
||||
// Archives rather than deletes: a fact dropped by mistake stays recoverable.
|
||||
forgetMemory(name: string): Promise<void>;
|
||||
saveMemory(edit: MemoryEdit): Promise<void>;
|
||||
@@ -252,6 +254,9 @@ export interface AgentPort {
|
||||
browserTools(): Promise<BrowserToolsSettings>;
|
||||
// Writes the user file, then rebuilds: the tools are bound while assembling.
|
||||
saveBrowserTools(enabled: boolean): Promise<BrowserToolsSettings>;
|
||||
displayCurrency(): Promise<DisplayCurrencySettings>;
|
||||
// Applies live: the kernel announces it and every ledger rebinds; no rebuild.
|
||||
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>;
|
||||
progressWatch(): Promise<ProgressWatchSettings>;
|
||||
// Applies to the running turn at its next round; no rebuild.
|
||||
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
|
||||
@@ -429,6 +434,10 @@ export interface AgentPort {
|
||||
// only this says how, and asking per path is what keeps a session that
|
||||
// touched two hundred files from shipping two hundred diffs nobody opened.
|
||||
changeDiff(path: string): Promise<ChangeDiff>;
|
||||
// Drafts a message for what is staged. Nothing is committed: the person edits
|
||||
// the text, and commitStaged records it locally once they confirm.
|
||||
proposeCommit(signal?: AbortSignal): Promise<CommitProposal>;
|
||||
commitStaged(req: CommitRequest): Promise<CommitResult>;
|
||||
// Dot entries are left out unless hidden is set; VCS stores never appear.
|
||||
workspaceFiles(path?: string, query?: string, hidden?: boolean): Promise<WorkspaceFiles>;
|
||||
workspaceFile(path: string): Promise<WorkspaceFile>;
|
||||
@@ -543,7 +552,8 @@ export interface AgentPort {
|
||||
// The person's answer to "trust this folder?" for the session's workspace.
|
||||
decideWorkspaceTrust(trust: "trusted" | "declined"): Promise<void>;
|
||||
setPreset(preset: Preset): Promise<void>;
|
||||
setModel(ref: string): Promise<void>;
|
||||
// Switches this session only; asDefault also records it as the model new sessions start on.
|
||||
setModel(ref: string, asDefault?: boolean): Promise<void>;
|
||||
setEffort(effort: string): Promise<void>;
|
||||
// "" turns the session's model mode off.
|
||||
setModelMode(mode: string): Promise<void>;
|
||||
|
||||
@@ -89,6 +89,9 @@ export interface Protocol {
|
||||
// gateway speaks, only which ones it answers.
|
||||
export interface ProviderProbe {
|
||||
kind: string;
|
||||
// The address chat must be rooted at: what was typed, completed with /v1 only
|
||||
// when the model list answered there alone.
|
||||
baseUrl?: string;
|
||||
// Every kind that listing may be driven with, kernel order. `kind` is the
|
||||
// pre-selection among them, not the only answer: DeepSeek serves both the
|
||||
// OpenAI chat wire and the Responses API off one model list.
|
||||
@@ -112,6 +115,8 @@ export interface ProviderProbe {
|
||||
export interface ProviderCheck {
|
||||
ok: boolean;
|
||||
kind?: string;
|
||||
// Present only when the saved address is not the one chat needs.
|
||||
baseUrl?: string;
|
||||
// Whether that answer is consistent with the kind the entry records. A
|
||||
// Responses source answers the OpenAI listing, so equality is the wrong test.
|
||||
matches?: boolean;
|
||||
@@ -143,6 +148,8 @@ export interface ProviderModelCheck {
|
||||
status: ProviderModelCheckStatus;
|
||||
reason?: ProviderModelCheckReason;
|
||||
httpStatus?: number;
|
||||
// The endpoint's own error text, for display; never an input to status or reason.
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelCheckRequest {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 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, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type Attachment, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
|
||||
import { SseFeedback } from "./sse_feedback";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
@@ -97,9 +97,21 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
return body;
|
||||
}
|
||||
|
||||
usage(days: number, source?: string) {
|
||||
const q = new URLSearchParams({ days: String(days) });
|
||||
if (source && source !== "all") q.set("source", source);
|
||||
usage(days: number, source?: string): Promise<UsageReport>;
|
||||
usage(query: UsageQuery): Promise<UsageReport>;
|
||||
usage(query: number | UsageQuery, source?: string) {
|
||||
const q = new URLSearchParams();
|
||||
if (typeof query === "number") {
|
||||
q.set("days", String(query));
|
||||
if (source && source !== "all") q.set("source", source);
|
||||
} else if ("days" in query) {
|
||||
q.set("days", String(query.days));
|
||||
if (query.source && query.source !== "all") q.set("source", query.source);
|
||||
} else {
|
||||
q.set("from", query.from);
|
||||
q.set("to", query.to);
|
||||
if (query.source && query.source !== "all") q.set("source", query.source);
|
||||
}
|
||||
return this.get<UsageReport>("/usage?" + q);
|
||||
}
|
||||
memories() {
|
||||
@@ -431,6 +443,18 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
return this.get<ChangeDiff>(`/changes/diff?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
async proposeCommit(signal?: AbortSignal) {
|
||||
const res = await fetch(this.base + "/commit/propose", {
|
||||
method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin", body: "{}", signal,
|
||||
});
|
||||
if (!res.ok) await SsePort.fail("/commit/propose", res);
|
||||
return (await res.json()) as CommitProposal;
|
||||
}
|
||||
|
||||
async commitStaged(req: CommitRequest) {
|
||||
return this.post0<CommitResult>("/commit", req);
|
||||
}
|
||||
|
||||
workspaceFiles(path = "", query = "", hidden = false) {
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set("path", path);
|
||||
@@ -752,8 +776,8 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
setPreset(preset: Preset) {
|
||||
return this.post("/preset", { preset });
|
||||
}
|
||||
setModel(ref: string) {
|
||||
return this.post("/model", { ref });
|
||||
setModel(ref: string, asDefault = false) {
|
||||
return this.post("/model", asDefault ? { ref, default: true } : { ref });
|
||||
}
|
||||
setEffort(effort: string) {
|
||||
return this.post("/effort", { effort });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SseShell } from "./sse_shell";
|
||||
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { ProgressWatchSettings } from "./boundary";
|
||||
import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings } from "./boundary";
|
||||
|
||||
// Where the agent may reach: the permission rules a call is matched against and
|
||||
// the sandbox the shell runs in.
|
||||
@@ -32,6 +32,12 @@ export class SseBoundary extends SseShell {
|
||||
saveBrowserTools(enabled: boolean) {
|
||||
return this.post0<BrowserToolsSettings>("/browser-tools", { enabled });
|
||||
}
|
||||
displayCurrency() {
|
||||
return this.get<DisplayCurrencySettings>("/display-currency");
|
||||
}
|
||||
saveDisplayCurrency(mode: DisplayCurrencyMode) {
|
||||
return this.post0<DisplayCurrencySettings>("/display-currency", { mode });
|
||||
}
|
||||
progressWatch() {
|
||||
return this.get<ProgressWatchSettings>("/progress-watch");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SseTheme } from "./sse_theme";
|
||||
import type { ThemeImport } from "./look";
|
||||
|
||||
const pack = { id: "dusk", name: "Dusk", tokens: { light: { bg: "#FFFFFF" } } };
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function reply(body: ThemeImport) {
|
||||
const fetch = vi.fn().mockResolvedValue(Response.json(body));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
return fetch;
|
||||
}
|
||||
|
||||
it.each(["loose-first", "archive-first", "two-archives"])("rejects an ambiguous ZIP selection before reading or uploading any file (%s)", async (order) => {
|
||||
const zip = new File(["archive fixture"], "Dusk.ZIP");
|
||||
const other = new File(["other fixture"], order === "two-archives" ? "other.zip" : "theme.json");
|
||||
const readZip = vi.spyOn(zip, "arrayBuffer");
|
||||
const readOther = vi.spyOn(other, "arrayBuffer");
|
||||
const fetch = reply({ pack, ignored: ["theme.json"] });
|
||||
await expect(new SseTheme().importTheme(order === "loose-first" ? [other, zip] : [zip, other])).rejects.toThrow();
|
||||
expect(readZip).not.toHaveBeenCalled();
|
||||
expect(readOther).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ ignored: undefined }, { ignored: ["notes.txt"] }])("preserves a single ZIP's complete canonical result (%j)", async ({ ignored }) => {
|
||||
const response = ignored ? { pack, ignored } : { pack };
|
||||
const fetch = reply(response);
|
||||
expect(await new SseTheme().importTheme([new File(["archive fixture"], "Dusk.ZIP")])).toEqual(response);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ name: "Dusk.ZIP", zip: btoa("archive fixture") });
|
||||
});
|
||||
|
||||
it("uploads every loose file and preserves the kernel's ignored-file list without reinterpretation", async () => {
|
||||
const response = { pack, ignored: ["notes.txt", "notes.txt"] };
|
||||
const fetch = reply(response);
|
||||
const got = await new SseTheme().importTheme([
|
||||
new File(["manifest fixture"], "theme.json"),
|
||||
new File(["image fixture"], "preview.png"),
|
||||
]);
|
||||
expect(got).toEqual(response);
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||
files: { "theme.json": btoa("manifest fixture"), "preview.png": btoa("image fixture") },
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates a single-ZIP upload failure without a successful receipt", async () => {
|
||||
const failure = new Error("theme import unavailable");
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(failure));
|
||||
await expect(new SseTheme().importTheme([new File(["archive fixture"], "dusk.zip")])).rejects.toBe(failure);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SseNetwork } from "./sse_network";
|
||||
import type { ThemeImport, ThemePack } from "./port";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// Theme packs, and where an extension's view is allowed to sit. Both are the
|
||||
// user overruling a default the pack or the extension asked for.
|
||||
@@ -13,6 +14,7 @@ export class SseTheme extends SseNetwork {
|
||||
// JSON, not multipart: csrfGuard admits nothing else.
|
||||
async importTheme(files: File[]) {
|
||||
const zip = files.find((f) => f.name.toLowerCase().endsWith(".zip"));
|
||||
if (zip && files.length > 1) throw new Error(t("请单独选择一个 .zip,或选择不含压缩包的 theme.json 和图片。"));
|
||||
if (zip) return this.post0<ThemeImport>("/themes/import", { name: zip.name, zip: await base64Of(zip) });
|
||||
const bodies: Record<string, string> = {};
|
||||
for (const f of files) bodies[f.name] = await base64Of(f);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SsePort } from "./sse";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("usage range query", () => {
|
||||
it("sends explicit calendar dates instead of folding them back into a day count", async () => {
|
||||
let called = "";
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
called = url;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
});
|
||||
|
||||
await new SsePort().usage({ from: "2026-08-01", to: "2026-08-31" });
|
||||
|
||||
expect(called).toBe("/usage?from=2026-08-01&to=2026-08-31");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,15 @@
|
||||
// up: a single total would have to invent an exchange rate, and the rate a turn
|
||||
// was billed at is not the rate today. An empty list means the records carry no
|
||||
// cost for that span, which is not the same as having cost nothing.
|
||||
// A usage window is either a trailing day count or an explicit inclusive
|
||||
// calendar range. Keeping them separate prevents "from" from silently riding
|
||||
// on a days query and being ignored by older kernels.
|
||||
export const DEFAULT_USAGE_DAYS = 30;
|
||||
|
||||
export type UsageQuery =
|
||||
| { days: number; source?: string }
|
||||
| { from: string; to: string; source?: string };
|
||||
|
||||
export interface Money {
|
||||
amount: string;
|
||||
currency: string;
|
||||
|
||||
@@ -51,3 +51,35 @@ export interface WorkspaceInfo {
|
||||
recents: WorkspaceEntry[];
|
||||
isolated?: boolean;
|
||||
}
|
||||
|
||||
export interface CommitFile {
|
||||
path: string;
|
||||
// git raw status letter: "A", "M", "D", "T".
|
||||
status: string;
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
// The name marks a file that holds secrets; its diff is never sent to a model.
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
// The staged set as read, and the message the model proposed for it.
|
||||
// fingerprint names that set: committing hands it back, and the kernel refuses
|
||||
// an index that has since changed.
|
||||
export interface CommitProposal {
|
||||
message: string;
|
||||
fingerprint: string;
|
||||
files: CommitFile[];
|
||||
truncated: boolean;
|
||||
contentSecrets: boolean;
|
||||
}
|
||||
|
||||
export interface CommitRequest {
|
||||
message: string;
|
||||
fingerprint: string;
|
||||
acknowledgeSecrets: boolean;
|
||||
}
|
||||
|
||||
export interface CommitResult {
|
||||
hash: string;
|
||||
subject: string;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ export function nameQueued(s: SessionState, id: string, itemId: string, queued:
|
||||
// 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.takenBack.includes(itemId)) {
|
||||
return { ...s, items: s.items.filter((i) => !(i.t === "user" && i.id === id)) };
|
||||
}
|
||||
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)) };
|
||||
}
|
||||
|
||||
@@ -240,6 +240,11 @@ describe("a line that is still queued", () => {
|
||||
expect(rows(st)[0].queued).toBe("followup");
|
||||
});
|
||||
|
||||
it("does not name a row for an entry taken back before its receipt landed", () => {
|
||||
const st = run([typed("row-1", "x"), { kind: "__unsent", id: "inbox-9" } as SessionEvent, queued("row-1", "inbox-9", "steer")]);
|
||||
expect(rows(st)).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes the row away once the kernel drops it", () => {
|
||||
const waiting = run([typed("row-1", "x"), queued("row-1", "inbox-9", "steer")]);
|
||||
expect(rows(reduce(waiting, { kind: "__unsent", id: "row-1" } as SessionEvent))).toEqual([]);
|
||||
|
||||
@@ -52,7 +52,7 @@ export const initialState: SessionState = {
|
||||
waiting: {},
|
||||
running: false,
|
||||
doing: "空闲",
|
||||
steerQueue: [], subagentPhase: {},
|
||||
steerQueue: [], takenBack: [], subagentPhase: {},
|
||||
awaitingTurnStart: [],
|
||||
queueMoved: 0,
|
||||
browserTabsMoved: 0,
|
||||
@@ -198,7 +198,7 @@ export type SessionEvent =
|
||||
| WireEvent
|
||||
| { kind: "__restore"; items: Item[]; plan?: PlanStep[]; executions: Executions }
|
||||
| { kind: "__todos"; plan: PlanStep[] }
|
||||
| { kind: "__totals"; hit: number; miss: number; cost?: number; coverage?: CostCoverage; incompleteReason?: string }
|
||||
| { kind: "__totals"; hit: number; miss: number; cost?: number; currency?: string; coverage?: CostCoverage; incompleteReason?: string }
|
||||
| { kind: "__error"; text: string }
|
||||
| { kind: "__user"; text: string; pending: boolean; id?: string }
|
||||
| { kind: "__unsent"; id: string }
|
||||
@@ -243,7 +243,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
return {
|
||||
...s,
|
||||
items: s.items.filter((i) => !(i.t === "user" && (i.id === ev.id || i.itemId === ev.id))),
|
||||
steerQueue: s.steerQueue,
|
||||
takenBack: [...s.takenBack, ev.id].slice(-32),
|
||||
};
|
||||
}
|
||||
// The card stays after the fact is dropped, marked: the transcript is the
|
||||
@@ -314,6 +314,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
hit: ev.hit,
|
||||
miss: ev.miss,
|
||||
cost: ev.cost ?? s.metrics.cost,
|
||||
currency: ev.currency || s.metrics.currency,
|
||||
coverage: ev.coverage ?? s.metrics.coverage,
|
||||
incompleteReason: ev.coverage ? ev.incompleteReason ?? "" : s.metrics.incompleteReason,
|
||||
},
|
||||
|
||||
@@ -219,6 +219,9 @@ export interface SessionState {
|
||||
running: boolean;
|
||||
doing: string;
|
||||
steerQueue: string[];
|
||||
// Queue ids this window gave up, so a receipt that lands after the withdrawal
|
||||
// cannot name a row for an entry that no longer exists.
|
||||
takenBack: string[];
|
||||
// The rows whose turns have not started yet, oldest first: each send that the
|
||||
// kernel has not yet named a message for. A steer never joins them, because
|
||||
// it starts no turn of its own.
|
||||
|
||||
@@ -1397,3 +1397,18 @@ what exists.
|
||||
- The reduced-motion block is deliberately the last motion rule in the file, so a
|
||||
new component inherits the user's request instead of depending on a selector
|
||||
written before it.
|
||||
|
||||
## MCP authorization state
|
||||
|
||||
- A server awaiting project launch approval uses a static `--warn-ink` pip.
|
||||
It is an authorization decision, so it does not use the animated connecting
|
||||
indicator or the failed-connection color.
|
||||
|
||||
## MCP rows in narrow settings groups
|
||||
|
||||
- Below 560px of available row width, server identity, status metadata and
|
||||
actions use separate lines. Status and provenance wrap instead of disappearing
|
||||
behind an ellipsis; long unbroken source strings may break within the line.
|
||||
- The action group keeps its switches and connection buttons inside the row,
|
||||
with room to wrap when needed. The container boundary scopes this to MCP rows,
|
||||
including rows inside packages; wide server rows retain their inline layout.
|
||||
@@ -2756,6 +2756,7 @@
|
||||
.srv[data-st="ready"] .pip { background: var(--ok) }
|
||||
.srv[data-st="connecting"] .pip { background: var(--net); animation: tick 1.6s ease-in-out infinite }
|
||||
.srv[data-st="failed"] .pip { background: var(--err) }
|
||||
.srv[data-st="pending"] .pip { background: var(--warn-ink) }
|
||||
.srv[data-st="disabled"] .pip { background: var(--ghost) }
|
||||
.srv[data-st="disabled"] .nm, .srv[data-st="disabled"] .meta { color: var(--faint) }
|
||||
|
||||
@@ -2766,6 +2767,19 @@
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
|
||||
.srv .acts { flex: none }
|
||||
.mcp-server { container: mcp-server / inline-size }
|
||||
@container mcp-server (max-width: 560px) {
|
||||
.srv > summary, .srv > .srv-hd { flex-wrap: wrap; row-gap: 8px }
|
||||
.srv > summary > .nm, .srv > .srv-hd > .nm { flex: 1; min-width: 0 }
|
||||
.srv > summary > .meta, .srv > .srv-hd > .meta {
|
||||
order: 1; flex: 1 0 calc(100% - 15px); margin-left: 15px;
|
||||
overflow: visible; overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
.srv > summary > .exc, .srv > .srv-hd > .exc { order: 2; margin-left: 15px }
|
||||
.srv > summary > .acts, .srv > .srv-hd > .acts {
|
||||
order: 2; margin-left: auto; max-width: 100%; flex-wrap: wrap; justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
@@ -3038,6 +3052,8 @@
|
||||
.mevidence > i { width: 5px; height: 5px; margin-left: 2px; border-radius: 50%; background: var(--ghost) }
|
||||
.mevidence > i[data-state="available"] { background: var(--ok) }
|
||||
.mevidence > i[data-state="unavailable"] { background: var(--err) }
|
||||
.mevidence > .mhttp { font-family: var(--mono) }
|
||||
.mevidence > .mdetail { flex-basis: 100%; font-family: var(--mono); overflow-wrap: anywhere }
|
||||
.mevidence > i[data-state="checking"] { background: var(--net) }
|
||||
.mops { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px }
|
||||
.mline .mverify {
|
||||
@@ -5389,12 +5405,31 @@
|
||||
.uranges { display: flex; gap: 3px; padding: 3px; background: var(--overlay); border-radius: var(--r-pill) }
|
||||
.uranges button {
|
||||
padding: 5px 13px; border: 0; background: none; cursor: pointer; border-radius: var(--r-pill);
|
||||
flex: none; white-space: nowrap;
|
||||
font: var(--w-med) var(--fs-ui) var(--ui); color: var(--muted);
|
||||
transition: color var(--t-line) var(--ease), background var(--t-line) var(--ease);
|
||||
}
|
||||
.uranges button:hover { color: var(--text) }
|
||||
.uranges button[aria-pressed="true"] { background: var(--raised); color: var(--text) }
|
||||
.uspan { color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--mono) }
|
||||
.udates {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)) auto; gap: 10px;
|
||||
align-items: end; padding: 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--overlay);
|
||||
}
|
||||
.udates label, .umonth {
|
||||
display: flex; flex-direction: column; gap: 5px;
|
||||
color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui);
|
||||
}
|
||||
.udates input, .umonth input {
|
||||
min-width: 0; padding: 7px 9px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--mono); color-scheme: light dark;
|
||||
}
|
||||
.udates button, .umonth button {
|
||||
min-height: 34px; padding: 7px 14px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--raised); color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); cursor: pointer;
|
||||
}
|
||||
.udates button:disabled { color: var(--faint); cursor: default }
|
||||
.umonth { max-width: 260px; margin-top: -4px }
|
||||
|
||||
.uhero { padding: 22px 24px; background: var(--raised); border: 1px solid var(--border); border-radius: var(--r-md) }
|
||||
.uhero-k { color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); letter-spacing: var(--track-lead); text-transform: uppercase }
|
||||
@@ -5404,7 +5439,7 @@
|
||||
padding: 4px 10px; border-radius: var(--r-pill); background: var(--overlay);
|
||||
color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui);
|
||||
}
|
||||
.uhero-note { margin: 11px 0 0; max-width: 62ch; color: var(--fg-2, var(--muted)); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui) }
|
||||
.uhero-note { margin: 11px 0 0; max-width: 62ch; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui) }
|
||||
|
||||
.utiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); gap: 12px }
|
||||
.utile { padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md) }
|
||||
@@ -5412,7 +5447,7 @@
|
||||
.utile .v { margin-top: 6px; font: var(--w-med) var(--fs-display)/1.1 var(--mono); font-variant-numeric: tabular-nums; letter-spacing: var(--track-tight) }
|
||||
.utile .m { margin-top: 4px; color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--ui) }
|
||||
|
||||
.ucard { padding: 16px 18px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md) }
|
||||
.ucard { min-width: 0; padding: 16px 18px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md) }
|
||||
.uhero, .utile, .ucard, .storage .item {
|
||||
transition: border-color var(--t-card) var(--ease-out), box-shadow var(--t-card) var(--ease-out);
|
||||
}
|
||||
@@ -5422,13 +5457,13 @@
|
||||
box-shadow: var(--lit), 0 16px 34px -30px color-mix(in srgb, var(--net) 30%, transparent);
|
||||
}
|
||||
}
|
||||
.ucard-h { display: flex; align-items: baseline; justify-content: space-between; gap: 10px }
|
||||
.ucard-h { display: flex; align-items: baseline; justify-content: space-between; gap: 4px 10px; flex-wrap: wrap }
|
||||
.ucard-h h3 { margin: 0; font: var(--w-med) var(--fs-ui) var(--ui) }
|
||||
.ucard-h .hint { color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--ui) }
|
||||
.ucard-s { margin: 2px 0 12px; color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--ui) }
|
||||
.utwo { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px }
|
||||
.unote { margin-top: 12px; padding: 10px 12px; background: var(--overlay); border-radius: var(--r-sm);
|
||||
color: var(--fg-2, var(--muted)); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui) }
|
||||
color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui) }
|
||||
|
||||
.uplot { position: relative; margin-top: 8px }
|
||||
.uplot svg { display: block; width: 100%; overflow: visible }
|
||||
@@ -5447,15 +5482,48 @@
|
||||
.utip .d { color: var(--muted); font: var(--fs-meta) var(--mono) }
|
||||
.utip .v { margin-top: 1px; font: var(--w-med) var(--fs-ui) var(--mono); font-variant-numeric: tabular-nums }
|
||||
|
||||
.udaily { padding-top: 14px }
|
||||
.utable-wrap { max-height: 320px; margin-top: 8px; overflow: auto; border: 1px solid var(--border); border-radius: var(--r-sm) }
|
||||
.utable { width: 100%; min-width: 460px; border-collapse: collapse; font: var(--w-reg) var(--fs-meta) var(--mono); font-variant-numeric: tabular-nums }
|
||||
.utable th, .utable td { padding: 8px 10px; border-bottom: 1px solid var(--hair); text-align: right; white-space: nowrap }
|
||||
.utable th:first-child, .utable td:first-child {
|
||||
position: sticky; left: 0; z-index: 2; background: var(--surface);
|
||||
box-shadow: 1px 0 0 var(--hair); text-align: left;
|
||||
}
|
||||
.utable th:first-child { z-index: 3 }
|
||||
.utable th { position: sticky; top: 0; z-index: 1; background: var(--surface); color: var(--muted); font-weight: var(--w-med) }
|
||||
.utable tr:last-child td { border-bottom: 0 }
|
||||
.utable td:not(:first-child) { color: var(--muted) }
|
||||
.utable .ud { color: var(--text) }
|
||||
.utable-scroll-hint { display: none }
|
||||
.utable-wrap:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.urows, .urow, .urow-t { min-width: 0 }
|
||||
.urows { display: flex; flex-direction: column; gap: 10px; margin-top: 8px }
|
||||
.urow-t { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 4px }
|
||||
.urow-t .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--fs-ui) var(--mono) }
|
||||
.urow-t .v { flex: none; color: var(--muted); font: var(--fs-ui) var(--mono); font-variant-numeric: tabular-nums }
|
||||
.urow-t .n { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--fs-ui) var(--mono) }
|
||||
.urow-t .v { flex: none; white-space: nowrap; color: var(--muted); font: var(--fs-ui) var(--mono); font-variant-numeric: tabular-nums }
|
||||
.utrack { height: 6px; background: var(--hair); border-radius: var(--r-pill); overflow: hidden }
|
||||
|
||||
.ufill { display: block; height: 100%; background: var(--chart); border-radius: var(--r-pill);
|
||||
.ufill { display: block; height: 100%; background: var(--row-color, var(--chart)); border-radius: var(--r-pill);
|
||||
transition: width var(--t-live) var(--ease-spring) }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.uhead { align-items: stretch }
|
||||
.uranges { width: 100%; overflow-x: auto }
|
||||
.uspan { width: 100%; text-align: center }
|
||||
.uhero { padding: 18px 16px }
|
||||
.uhero-v { font-size: 30px; overflow-wrap: anywhere }
|
||||
.utiles { grid-template-columns: repeat(2, minmax(0, 1fr)) }
|
||||
.ucard { padding: 14px 12px 10px }
|
||||
.utwo { grid-template-columns: minmax(0, 1fr) }
|
||||
.udates { grid-template-columns: 1fr }
|
||||
.udates button { width: 100% }
|
||||
.umonth { max-width: none }
|
||||
.utable th, .utable td { padding: 7px 8px }
|
||||
.utable-scroll-hint { display: inline }
|
||||
.utable-scroll-hint::before { content: " · " }
|
||||
}
|
||||
|
||||
.memedit { display: flex; flex-direction: column; gap: 10px }
|
||||
.memedit label { display: flex; flex-direction: column; gap: 4px;
|
||||
color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); letter-spacing: var(--track-lead) }
|
||||
|
||||
@@ -1416,6 +1416,8 @@ body { background: var(--page) !important; }
|
||||
.workbench-changes { flex: none; max-height: 34%; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; border-bottom: 1px solid var(--hair); scrollbar-gutter: stable; }
|
||||
.workbench-changes .workbench-explorer-head { position: sticky; top: 0; z-index: 1; border-bottom: 0; background: var(--page); }
|
||||
.workbench-changes > button { flex: none; }
|
||||
.workbench-changes:has(.commit-card:not([data-phase="idle"])) { max-height: 80%; }
|
||||
.workbench-changes .commit-card { margin: 6px 10px 8px; }
|
||||
.workbench-change-row {
|
||||
width: 100%; min-height: 26px; padding: 0 10px 0 8px; display: flex; align-items: center; gap: 7px;
|
||||
border: 0; background: none; color: var(--muted); font: 450 11.5px/1.35 var(--ui);
|
||||
@@ -4041,7 +4043,48 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.refine-wait { margin: 0; color: var(--faint); font: 400 12px/1.5 var(--ui); }
|
||||
.refine-error { margin: 0; color: var(--err); font: 400 12px/1.5 var(--ui); }
|
||||
.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
:root[data-theme="light"] .refine-card { background: var(--page); }
|
||||
.commit-card { display: grid; gap: 8px; margin: 8px 0 0; }
|
||||
.commit-open { margin-left: auto; }
|
||||
.workbench-explorer-head .commit-open { height: 20px; padding: 0 8px; font-size: 11px; }
|
||||
.commit-files li i { font-style: normal; }
|
||||
.commit-files li i[data-io="up"] { color: var(--ok); }
|
||||
.commit-files li i[data-io="down"] { color: var(--err); }
|
||||
.commit-card:not([data-phase="idle"]) {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--hair);
|
||||
}
|
||||
.commit-card header { display: flex; align-items: center; gap: 6px; color: var(--muted); font: 600 11px/1.4 var(--ui); }
|
||||
.commit-card header b { color: var(--text); font-weight: 600; }
|
||||
.commit-card header small { margin-left: auto; }
|
||||
.commit-card small { color: var(--ghost); font: 400 10px/1.4 var(--ui); }
|
||||
.commit-card footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
|
||||
.commit-done { margin: 0; color: var(--text); font: 400 12px/1.5 var(--ui); overflow-wrap: anywhere; }
|
||||
.commit-files { display: grid; gap: 2px; max-height: 120px; margin: 0; padding: 0; overflow: auto; list-style: none; font: 400 11px/1.5 var(--mono); }
|
||||
.commit-files li { display: flex; gap: 6px; min-width: 0; color: var(--muted); }
|
||||
.commit-path { display: flex; flex: 1; min-width: 0; }
|
||||
.commit-path .commit-dir { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; }
|
||||
.commit-path .commit-dir::after { content: "\200E"; }
|
||||
.commit-path .commit-base { flex: none; white-space: nowrap; }
|
||||
.commit-files li[data-sensitive] { color: var(--err); }
|
||||
.commit-warn { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--err); border-radius: 8px; color: var(--err); font: 400 11px/1.5 var(--ui); }
|
||||
.commit-warn p { margin: 0; overflow-wrap: anywhere; }
|
||||
.commit-warn label { display: flex; align-items: center; gap: 6px; color: var(--text); }
|
||||
.commit-message {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--page);
|
||||
color: var(--text);
|
||||
font: 400 12px/1.5 var(--mono);
|
||||
}
|
||||
:root[data-theme="light"] .refine-card,
|
||||
:root[data-theme="light"] .commit-card:not([data-phase="idle"]) { background: var(--page); }
|
||||
:root[data-theme="light"] .commit-message { background: var(--bg); }
|
||||
@media (hover: none) {
|
||||
.flow .user-acts,
|
||||
.wsdel,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const RAW = import.meta.glob(["./app.css", "./tokens.css", "./TOKENS.md"], {
|
||||
query: "?raw", import: "default", eager: true,
|
||||
}) as Record<string, string>;
|
||||
const SHEET = RAW["./app.css"];
|
||||
const TOKENS = RAW["./tokens.css"];
|
||||
const TOKEN_DOC = RAW["./TOKENS.md"];
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
function linearize(value: number): number {
|
||||
const c = value / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function encode(value: number): number {
|
||||
const c = Math.max(0, Math.min(1, value));
|
||||
return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): Rgb {
|
||||
return [1, 3, 5].map((at) => Number.parseInt(hex.slice(at, at + 2), 16)) as Rgb;
|
||||
}
|
||||
|
||||
function relativeLuminance([r, g, b]: Rgb): number {
|
||||
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
||||
}
|
||||
|
||||
function contrast(a: Rgb, b: Rgb): number {
|
||||
const x = relativeLuminance(a), y = relativeLuminance(b);
|
||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
||||
}
|
||||
|
||||
function oklchToRgb(lightness: number, chroma: number, hue: number): Rgb {
|
||||
const angle = hue * Math.PI / 180;
|
||||
const a = chroma * Math.cos(angle), b = chroma * Math.sin(angle);
|
||||
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
||||
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
||||
const s = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3;
|
||||
return [
|
||||
encode(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
encode(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
encode(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
|
||||
];
|
||||
}
|
||||
|
||||
function hexToOklch(hex: string): { l: number; c: number } {
|
||||
const [r, g, b] = hexToRgb(hex).map(linearize);
|
||||
const l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3);
|
||||
const m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3);
|
||||
const s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3);
|
||||
const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
|
||||
const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
|
||||
const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
|
||||
return { l: L, c: Math.hypot(a, b2) };
|
||||
}
|
||||
|
||||
function minSourceContrast(chart: string, track: Rgb): number {
|
||||
const { l, c } = hexToOklch(chart);
|
||||
let minimum = Infinity;
|
||||
for (let hue = 0; hue < 360; hue++) minimum = Math.min(minimum, contrast(oklchToRgb(l, c, hue), track));
|
||||
return minimum;
|
||||
}
|
||||
|
||||
describe("usage remains readable on a phone", () => {
|
||||
it("derives source colours from chart tokens that clear 3:1 in both themes", () => {
|
||||
const lightChart = SHEET.match(/\.usage\s*\{[^}]*--chart:\s*(#[0-9a-f]{6})/i)?.[1] ?? "";
|
||||
const darkChart = SHEET.match(/\[data-theme="dark"\]\s*\.usage\s*\{[^}]*--chart:\s*(#[0-9a-f]{6})/i)?.[1] ?? "";
|
||||
expect(lightChart).toBeTruthy();
|
||||
expect(darkChart).toBeTruthy();
|
||||
expect(TOKENS).toContain("--hair: oklch(91.7% .0126 85)");
|
||||
expect(TOKENS).toContain("--hair: oklch(24.2% .016 255)");
|
||||
|
||||
expect(minSourceContrast(lightChart, oklchToRgb(0.917, 0.0126, 85))).toBeGreaterThanOrEqual(3.5);
|
||||
expect(minSourceContrast(darkChart, oklchToRgb(0.242, 0.016, 255))).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("lets an explicit source colour override the shared chart hue", () => {
|
||||
expect(SHEET).toMatch(/\.ufill\s*\{[^}]*background:\s*var\(--row-color,\s*var\(--chart\)\)/);
|
||||
});
|
||||
|
||||
it("uses --muted instead of an undefined --fg-2 token", () => {
|
||||
expect(SHEET).not.toContain("--fg-2");
|
||||
expect(TOKENS).not.toContain("--fg-2");
|
||||
expect(TOKEN_DOC).not.toContain("--fg-2");
|
||||
expect(SHEET).toMatch(/\.utable td:not\(:first-child\)\s*\{\s*color:\s*var\(--muted\)/);
|
||||
});
|
||||
|
||||
it("keeps the full daily table in a bounded, hinted scroll region", () => {
|
||||
const rule = SHEET.match(/\.utable-wrap\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
expect(rule).toMatch(/max-height:\s*\d+px/);
|
||||
expect(rule).toMatch(/overflow:\s*auto/);
|
||||
const pinned = SHEET.match(/\.utable th:first-child,\s*\.utable td:first-child\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
expect(pinned).toMatch(/position:\s*sticky/);
|
||||
expect(pinned).toMatch(/left:\s*0/);
|
||||
expect(SHEET).toMatch(/\.utable-scroll-hint\s*\{\s*display:\s*none/);
|
||||
expect(SHEET).toMatch(/@media\s*\(max-width:\s*560px\)[\s\S]*?\.utable-scroll-hint\s*\{\s*display:\s*inline/);
|
||||
});
|
||||
|
||||
it("stacks cards and keeps range labels intact on a phone", () => {
|
||||
expect(SHEET).toMatch(/\.uranges button\s*\{[^}]*white-space:\s*nowrap/);
|
||||
expect(SHEET).toMatch(/@media\s*\(max-width:\s*560px\)[\s\S]*?\.utwo\s*\{\s*grid-template-columns:\s*minmax\(0,\s*1fr\)/);
|
||||
expect(SHEET).toMatch(/\.ucard\s*\{[^}]*min-width:\s*0/);
|
||||
expect(SHEET).toMatch(/\.urows,\s*\.urow,\s*\.urow-t\s*\{\s*min-width:\s*0/);
|
||||
expect(SHEET).toMatch(/\.urow-t\s+\.n\s*\{[^}]*min-width:\s*0[^}]*text-overflow:\s*ellipsis/);
|
||||
expect(SHEET).toMatch(/\.urow-t\s+\.v\s*\{[^}]*flex:\s*none[^}]*white-space:\s*nowrap/);
|
||||
});
|
||||
|
||||
it("stacks the custom date fields before they squeeze the inputs", () => {
|
||||
expect(SHEET).toMatch(/@media\s*\(max-width:\s*560px\)[\s\S]*?\.udates\s*\{[^}]*grid-template-columns:\s*1fr/);
|
||||
});
|
||||
});
|
||||
@@ -191,3 +191,26 @@ it("normalizes spaces around an ASCII name and refuses a blank one", async () =>
|
||||
name: "company-relay.1_x",
|
||||
})));
|
||||
});
|
||||
|
||||
it("saves the address the probe resolved, not the bare host that was typed", async () => {
|
||||
const saveProvider = vi.fn(async () => {});
|
||||
const probeProvider = vi.fn(async () => ({
|
||||
kind: "openai", kinds: ["openai"], baseUrl: "https://relay.example/v1", authHeader: false,
|
||||
models: ["model-x"], default: "model-x", efforts: [], effort: "", vision: [], ambiguous: false, noProxy: false,
|
||||
}));
|
||||
const port = {
|
||||
protocols: vi.fn(async () => [{ kind: "openai", discovery: "openai", serverWebSearch: false, reasoningParams: true }]),
|
||||
saveProvider,
|
||||
probeProvider,
|
||||
} as unknown as Port;
|
||||
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example");
|
||||
await userEvent.type(screen.getByLabelText("API Key"), "k");
|
||||
await userEvent.click(screen.getByRole("button", { name: /验证连接并读取/ }));
|
||||
|
||||
expect(await screen.findByText("接口地址已补全为 https://relay.example/v1")).toBeTruthy();
|
||||
expect((screen.getByLabelText("接口地址") as HTMLInputElement).value).toBe("https://relay.example/v1");
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加来源" }));
|
||||
await waitFor(() => expect(saveProvider).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: "https://relay.example/v1" })));
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { checkedFact, clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
|
||||
import { KIND_LABEL, hostOf, nameFrom, sourceNameUsable, vendorLabel } from "./vendors";
|
||||
import type { Port } from "./Providers";
|
||||
import { reason } from "../i18n/kernel";
|
||||
@@ -19,6 +19,7 @@ export function AddProvider({
|
||||
port: Port; taken: string[]; known: ProviderEntry[]; onDone: () => void; onCancel: () => void;
|
||||
}) {
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [completed, setCompleted] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [probe, setProbe] = useState<ProviderProbe | null>(null);
|
||||
const [catalog, setCatalog] = useState<Protocol[]>([]);
|
||||
@@ -77,6 +78,10 @@ export function AddProvider({
|
||||
try {
|
||||
const got = await port.probeProvider(baseUrl.trim(), apiKey.trim());
|
||||
setProbe(got);
|
||||
const resolved = got.baseUrl?.trim() ?? "";
|
||||
const changed = resolved !== "" && resolved !== baseUrl.trim();
|
||||
if (changed) setBaseUrl(resolved);
|
||||
setCompleted(changed ? resolved : "");
|
||||
setKind((current) => current || got.kind);
|
||||
setModels((current) => [...new Set([...got.models, ...current])]);
|
||||
setPicked((current) => current.length ? current : got.models.slice(0, 8));
|
||||
@@ -151,7 +156,7 @@ export function AddProvider({
|
||||
});
|
||||
setFacts((current) => ({
|
||||
...current,
|
||||
[model]: { ...(current[model] ?? { origin: "manual" }), status: got.status, reason: got.reason },
|
||||
[model]: checkedFact(current[model], "manual", got),
|
||||
}));
|
||||
} catch {
|
||||
setFacts((current) => ({
|
||||
@@ -333,6 +338,7 @@ export function AddProvider({
|
||||
)}
|
||||
</div>
|
||||
{probe && <p className="probe-ok">{t("连接可用 · 找到 {n} 个模型", { n: probe.models.length })}</p>}
|
||||
{completed !== "" && completed === baseUrl.trim() && <p className="probe-ok">{t("接口地址已补全为 {url}", { url: completed })}</p>}
|
||||
<div className="mlist">
|
||||
<div className="mlhead">
|
||||
<span className="ttl">{t("启用的模型")}</span>
|
||||
|
||||
@@ -137,7 +137,7 @@ function ServerInput({ port, canProject, onClose, onInstalled }: Props) {
|
||||
.map((k) => (
|
||||
<div className="risk" key={k.field} data-kind={k.kind}>
|
||||
<span className="lb">{t(KIND_LABEL[k.kind] ?? k.kind)}</span>
|
||||
<span className="dt">{k.kind === "secret" ? k.field.split(".").pop() : k.detail}</span>
|
||||
<span className="dt">{k.kind === "secret" ? k.field.split(".").pop() : k.kind === "unknown-host" ? (s.displayUrl ?? "<redacted>") : [s.displayCommand ?? "<redacted>", ...(s.displayArgs ?? [])].join(" ")}</span>
|
||||
{k.kind === "secret" && <span className="why">{k.detail}</span>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useFoldAway } from "./foldaway";
|
||||
import { useDrawerCloses } from "./drawer";
|
||||
import { RemoteAsk } from "./RemoteAsk";
|
||||
import { Feedback, type FeedbackTab } from "./Feedback";
|
||||
import { usePaneForSheets } from "./usePaneForSheets";
|
||||
import { BrowserLogin } from "./BrowserLogin";
|
||||
import type { RemoteAsk as RemoteAskT, RemoteHost } from "../port/remote";
|
||||
import { Boundary } from "./Boundary";
|
||||
@@ -489,27 +490,25 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// the window's, not whichever project happens to sit first in the tree.
|
||||
const newSessionRoot = activeWorkspace?.root;
|
||||
|
||||
// Every settings route is a runtime's, so with no pane open there is nothing
|
||||
// to read them from. Asking for settings then opens a session in the current
|
||||
// folder, as the empty state's own button would, and the sheet follows it.
|
||||
const prefsPane = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!settings || !panesRead || runtimes.length > 0) {
|
||||
prefsPane.current = false;
|
||||
return;
|
||||
}
|
||||
if (prefsPane.current) return;
|
||||
prefsPane.current = true;
|
||||
if (!newSessionRoot) {
|
||||
usePaneForSheets({
|
||||
settings: !!settings,
|
||||
feedback: feedback !== null,
|
||||
panesRead,
|
||||
panes: runtimes.length,
|
||||
root: newSessionRoot,
|
||||
openPane,
|
||||
refuse: (message) => {
|
||||
setSettings(false);
|
||||
setError(t("设置需要一个打开的会话。请先在左栏添加一个文件夹。"));
|
||||
return;
|
||||
}
|
||||
void openPane({ root: newSessionRoot }).catch((e) => {
|
||||
setFeedback(null);
|
||||
setError(message);
|
||||
},
|
||||
fail: (e) => {
|
||||
setSettings(false);
|
||||
setFeedback(null);
|
||||
fail(e);
|
||||
});
|
||||
}, [settings, panesRead, runtimes.length, newSessionRoot, openPane, fail]);
|
||||
},
|
||||
clear: (message) => setError((cur) => (cur === message ? "" : cur)),
|
||||
});
|
||||
|
||||
// The tab strip has nowhere to await: closing is the end of the gesture there,
|
||||
// so a refusal has to land in the error bar rather than in a caller.
|
||||
|
||||
@@ -35,6 +35,32 @@ describe("cloud backup", () => {
|
||||
expect(req.passphrase).toBe("a long passphrase");
|
||||
});
|
||||
|
||||
it("says why the button is held back while the passphrase is short or unconfirmed", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
render(<Backup port={port} />);
|
||||
const btn = await screen.findByRole<HTMLButtonElement>("button", { name: "备份到账号" });
|
||||
const [pass, again] = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(document.querySelector('[data-action="backup.wait"]')!.textContent).toBe("口令还差 10 个字符");
|
||||
await userEvent.type(pass!, "abcd");
|
||||
const why = document.querySelector('[data-action="backup.wait"]')!;
|
||||
expect(why.textContent).toBe("口令还差 6 个字符");
|
||||
expect(btn.getAttribute("aria-describedby")).toBe(why.id);
|
||||
await userEvent.type(pass!, "efghij");
|
||||
expect(document.querySelector('[data-action="backup.wait"]')!.textContent).toBe("请再输一次口令");
|
||||
await userEvent.type(again!, "abcdefghij");
|
||||
expect(document.querySelector('[data-action="backup.wait"]')).toBeNull();
|
||||
expect(btn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a category when every box is unticked", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
render(<Backup port={port} />);
|
||||
await screen.findByRole("button", { name: "备份到账号" });
|
||||
for (const box of screen.getAllByRole<HTMLInputElement>("checkbox")) if (box.checked) await userEvent.click(box);
|
||||
expect(document.querySelector('[data-action="backup.wait"]')!.textContent).toBe("至少选择一项备份内容");
|
||||
});
|
||||
|
||||
it("holds a command back until its own consent box is ticked, and sends that consent", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const apply = vi.spyOn(port, "applyBackup");
|
||||
|
||||
@@ -52,6 +52,14 @@ export function Backup({ port }: { port: AgentPort }) {
|
||||
const min = catalog?.minPassphrase ?? 10;
|
||||
const mismatch = again !== "" && pass !== again;
|
||||
const ready = chosen.size > 0 && pass.length >= min && pass === again && !busy;
|
||||
const wait =
|
||||
chosen.size === 0
|
||||
? t("至少选择一项备份内容")
|
||||
: pass.length < min
|
||||
? t("口令还差 {n} 个字符", { n: min - pass.length })
|
||||
: again === ""
|
||||
? t("请再输一次口令")
|
||||
: "";
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
@@ -124,9 +132,14 @@ export function Backup({ port }: { port: AgentPort }) {
|
||||
{mismatch && <p className="acct-note" data-err="">{t("两次输入的口令不一致")}</p>}
|
||||
|
||||
<div className="bk-acts">
|
||||
<button className="act" data-primary data-action="backup.create" disabled={!ready} onClick={() => void create()}>
|
||||
<button className="act" data-primary data-action="backup.create" disabled={!ready} aria-describedby={!ready && wait ? "bk-wait" : undefined} onClick={() => void create()}>
|
||||
{t(busy ? "正在加密上传…" : "备份到账号")}
|
||||
</button>
|
||||
{!ready && !busy && wait && (
|
||||
<span id="bk-wait" className="acct-note" aria-live="polite" data-action="backup.wait">
|
||||
{wait}
|
||||
</span>
|
||||
)}
|
||||
<span className="acct-note">
|
||||
{t("已用 {n}/{max} 份", { n: catalog.backups.length, max: catalog.limits.maxCount })}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AgentPort, CommitProposal, CommitResult } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
|
||||
type Phase =
|
||||
| { phase: "idle" }
|
||||
| { phase: "drafting" }
|
||||
| { phase: "ready"; proposal: CommitProposal; message: string; ack: boolean; committing: boolean; error?: string }
|
||||
| { phase: "done"; result: CommitResult }
|
||||
| { phase: "failed"; error: string };
|
||||
|
||||
// A proposal, not an action: the model drafts for what is staged, the person
|
||||
// edits, and only the confirm button records a local commit. Nothing here stages
|
||||
// a file or pushes.
|
||||
export function useCommitCard(port: AgentPort, onCommitted: () => void) {
|
||||
const [state, setState] = useState<Phase>({ phase: "idle" });
|
||||
const pending = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => pending.current?.abort(), []);
|
||||
|
||||
const draft = () => {
|
||||
pending.current?.abort();
|
||||
const ac = new AbortController();
|
||||
pending.current = ac;
|
||||
setState({ phase: "drafting" });
|
||||
port.proposeCommit(ac.signal).then(
|
||||
(proposal) =>
|
||||
!ac.signal.aborted && setState({ phase: "ready", proposal, message: proposal.message, ack: false, committing: false }),
|
||||
(e) => !ac.signal.aborted && setState({ phase: "failed", error: reason(e) }),
|
||||
);
|
||||
};
|
||||
const close = () => {
|
||||
pending.current?.abort();
|
||||
pending.current = null;
|
||||
setState({ phase: "idle" });
|
||||
};
|
||||
|
||||
const button = (
|
||||
<button type="button" className="btn sm commit-open" data-action="commit.draft" disabled={state.phase !== "idle"} onClick={draft}>
|
||||
{t("提交…")}
|
||||
</button>
|
||||
);
|
||||
if (state.phase === "idle") return { button, card: null };
|
||||
|
||||
if (state.phase === "drafting") {
|
||||
return {
|
||||
button,
|
||||
card: (
|
||||
<section className="commit-card" data-phase="drafting" aria-label={t("提交说明")}>
|
||||
<p className="refine-wait" role="status">{t("读取暂存区,起草提交说明…")}</p>
|
||||
<footer>
|
||||
<button type="button" className="btn sm" data-weak="" data-action="commit.close" onClick={close}>{t("取消")}</button>
|
||||
</footer>
|
||||
</section>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "failed") {
|
||||
return {
|
||||
button,
|
||||
card: (
|
||||
<section className="commit-card" data-phase="failed" aria-label={t("提交说明")}>
|
||||
<p className="refine-error" role="alert">{state.error}</p>
|
||||
<footer>
|
||||
<button type="button" className="btn sm" data-action="commit.draft" onClick={draft}>{t("起草提交说明")}</button>
|
||||
<button type="button" className="btn sm" data-weak="" data-action="commit.close" onClick={close}>{t("取消")}</button>
|
||||
</footer>
|
||||
</section>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "done") {
|
||||
return {
|
||||
button,
|
||||
card: (
|
||||
<section className="commit-card" data-phase="done" aria-label={t("提交说明")}>
|
||||
<p className="commit-done" role="status">
|
||||
{t("已提交 {hash}:{subject}", { hash: state.result.hash.slice(0, 7), subject: state.result.subject })}
|
||||
</p>
|
||||
<small>{t("只在本地提交,不会推送")}</small>
|
||||
<footer>
|
||||
<button type="button" className="btn sm" data-weak="" data-action="commit.close" onClick={close}>{t("收起")}</button>
|
||||
</footer>
|
||||
</section>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { proposal, message, ack, committing, error } = state;
|
||||
const sensitive = proposal.files.filter((f) => f.sensitive).map((f) => f.path);
|
||||
const warned = sensitive.length > 0 || proposal.contentSecrets;
|
||||
const set = (patch: Partial<typeof state>) => setState({ ...state, ...patch });
|
||||
const confirm = () => {
|
||||
set({ committing: true, error: undefined });
|
||||
port
|
||||
.commitStaged({ message, fingerprint: proposal.fingerprint, acknowledgeSecrets: ack })
|
||||
.then(
|
||||
(result) => {
|
||||
setState({ phase: "done", result });
|
||||
onCommitted();
|
||||
},
|
||||
(e) => setState({ ...state, committing: false, error: reason(e) }),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
button,
|
||||
card: (
|
||||
<section className="commit-card" data-phase="ready" aria-label={t("提交说明")}>
|
||||
<header>
|
||||
<b>{t("暂存的文件")}</b>
|
||||
<small>{t("{n} 个文件已暂存", { n: proposal.files.length })}</small>
|
||||
</header>
|
||||
<ul className="commit-files">
|
||||
{proposal.files.map((f) => (
|
||||
<li key={f.path} data-sensitive={f.sensitive ? "" : undefined} title={f.path}>
|
||||
<em className="st" data-s={f.status}>{f.status}</em>
|
||||
<CommitPath path={f.path} />
|
||||
{f.insertions !== undefined && <i data-io="up">+{f.insertions}</i>}
|
||||
{f.deletions !== undefined && <i data-io="down">-{f.deletions}</i>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{proposal.truncated && <small>{t("暂存区内容过长,只按前面一部分起草")}</small>}
|
||||
{warned && (
|
||||
<div className="commit-warn" role="alert">
|
||||
{sensitive.length > 0 && <p>{t("可能含有密钥:{files}", { files: sensitive.join(", ") })}</p>}
|
||||
{proposal.contentSecrets && <p>{t("新增的内容里有形似密钥的值")}</p>}
|
||||
<label>
|
||||
<input type="checkbox" data-action="commit.acknowledge" checked={ack} disabled={committing} onChange={(e) => set({ ack: e.target.checked })} />
|
||||
{t("我确认这些内容可以提交")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="commit-message"
|
||||
data-action="commit.edit"
|
||||
aria-label={t("提交说明")}
|
||||
value={message}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
disabled={committing}
|
||||
onChange={(e) => set({ message: e.target.value })}
|
||||
/>
|
||||
{error && <p className="refine-error" role="alert">{error}</p>}
|
||||
<small>{t("只在本地提交,不会推送")}</small>
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
data-primary
|
||||
data-action="commit.confirm"
|
||||
disabled={committing || !message.trim() || (warned && !ack)}
|
||||
onClick={confirm}
|
||||
>
|
||||
{committing ? t("正在提交…") : t("提交到本地")}
|
||||
</button>
|
||||
<button type="button" className="btn sm" data-action="commit.draft" disabled={committing} onClick={draft}>
|
||||
{t("重新起草")}
|
||||
</button>
|
||||
<button type="button" className="btn sm" data-weak="" data-action="commit.close" disabled={committing} onClick={close}>
|
||||
{t("取消")}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Only the directory gives way when the pane is narrow, from its left, so its tail
|
||||
// and the whole file name stay visible. The directory is rtl for that ellipsis side.
|
||||
function CommitPath({ path }: { path: string }) {
|
||||
const cut = path.lastIndexOf("/") + 1;
|
||||
return (
|
||||
<span className="commit-path">
|
||||
{cut > 0 && <span className="commit-dir">{path.slice(0, cut)}</span>}
|
||||
<span className="commit-base">{path.slice(cut)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -534,7 +534,8 @@ describe("a line taken back from the queue", () => {
|
||||
|
||||
it("restores the same text twice when it is taken back twice", () => {
|
||||
const p = props();
|
||||
const view = render(<Composer {...p} restore={{ n: 1, text: "甲" }} />);
|
||||
const view = render(<Composer {...p} />);
|
||||
view.rerender(<Composer {...p} restore={{ n: 1, text: "甲" }} />);
|
||||
const box = view.container.querySelector("textarea") as HTMLTextAreaElement;
|
||||
fireEvent.change(box, { target: { value: "", selectionStart: 0 } });
|
||||
view.rerender(<Composer {...p} restore={{ n: 2, text: "甲" }} />);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AgentPort, ChipCall, ModelEntry, SessionStatus, Attachment } from
|
||||
import { Picker } from "./Menu";
|
||||
import { Policy } from "./Policy";
|
||||
import { modelMenu } from "./modelmenu";
|
||||
import { effortMenu, effortReading, effortsFor, routeEffortPick } from "./effort";
|
||||
import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort";
|
||||
import { CompletionMenu, useCompletion } from "./Completion";
|
||||
import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
import { useIme } from "./ime";
|
||||
@@ -154,10 +154,13 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
queueMicrotask(() => box.current?.focus());
|
||||
}, [quote?.n]);
|
||||
|
||||
const restoredAt = useRef(restore?.n ?? 0);
|
||||
useEffect(() => {
|
||||
if (!restore?.n) return;
|
||||
if (!restore?.n || restore.n === restoredAt.current) return;
|
||||
restoredAt.current = restore.n;
|
||||
setText((prev) => {
|
||||
const next = prev.trim() ? `${prev.replace(/\s+$/, "")}\n${restore.text}` : restore.text;
|
||||
const next = !prev.trim() || prev.trim() === restore.text.trim()
|
||||
? restore.text : `${prev.replace(/\s+$/, "")}\n${restore.text}`;
|
||||
pending.current = next.length;
|
||||
return next;
|
||||
});
|
||||
@@ -375,6 +378,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
// may still carry one from a model that did, and printing that would be the
|
||||
// composer answering for an endpoint that never spoke.
|
||||
const declared = efforts.length > 0;
|
||||
const forcedThinking = forcesThinkingFor(models, status?.modelRef);
|
||||
const modelLb = status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—";
|
||||
// A model switch rebuilds the runtime kernel-side; other controls here may
|
||||
// land at once. Each click needs its own pending state: greying the whole
|
||||
@@ -727,7 +731,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
title={t("推理强度")}
|
||||
current={declared ? status.effort || "auto" : ""}
|
||||
pending={busy["effort"] || busy["mode"]}
|
||||
items={effortMenu(efforts, modelLb, "__effort-declare", status.modes)}
|
||||
items={effortMenu(efforts, modelLb, "__effort-declare", status.modes, forcedThinking)}
|
||||
onPick={(value) => routeEffortPick(value, status.modes, {
|
||||
declare: () => onSettings("providers:effort-declare"),
|
||||
effort: (level) => change("effort", () => port.setEffort(level)),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { DisplayCurrency } from "./DisplayCurrency";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { DisplayCurrencyMode, DisplayCurrencySettings } from "../port/boundary";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const base: DisplayCurrencySettings = { mode: "auto", path: "/u/config.toml" };
|
||||
|
||||
function portWith(save: (m: DisplayCurrencyMode) => Promise<DisplayCurrencySettings>) {
|
||||
return { displayCurrency: vi.fn(async () => base), saveDisplayCurrency: vi.fn(save) } as unknown as AgentPort & { saveDisplayCurrency: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
describe("the display currency setting", () => {
|
||||
it("offers three modes, marks the stored one, and writes a change once", async () => {
|
||||
const port = portWith(async (mode) => ({ ...base, mode }));
|
||||
const changed = vi.fn();
|
||||
render(<DisplayCurrency port={port} onChanged={changed} />);
|
||||
const auto = await screen.findByRole("button", { name: "自动" });
|
||||
expect(auto.getAttribute("aria-pressed")).toBe("true");
|
||||
const usd = screen.getByRole("button", { name: "美元 USD" });
|
||||
await userEvent.click(usd);
|
||||
expect(port.saveDisplayCurrency).toHaveBeenCalledTimes(1);
|
||||
expect(port.saveDisplayCurrency).toHaveBeenCalledWith("USD");
|
||||
await waitFor(() => expect(usd.getAttribute("aria-pressed")).toBe("true"));
|
||||
expect(auto.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not write when the chosen mode is already stored", async () => {
|
||||
const port = portWith(async (mode) => ({ ...base, mode }));
|
||||
render(<DisplayCurrency port={port} onChanged={() => {}} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "自动" }));
|
||||
expect(port.saveDisplayCurrency).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the old choice and says why when the kernel refuses", async () => {
|
||||
const port = portWith(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const changed = vi.fn();
|
||||
render(<DisplayCurrency port={port} onChanged={changed} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "人民币 CNY" }));
|
||||
await waitFor(() => expect(document.querySelector(".why")).toBeTruthy());
|
||||
const pressed = screen.getAllByRole("button").filter((b) => b.getAttribute("aria-pressed") === "true");
|
||||
expect(pressed.map((b) => b.textContent)).toEqual(["自动"]);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { DisplayCurrencyMode, DisplayCurrencySettings } from "../port/boundary";
|
||||
import { Group } from "./Group";
|
||||
|
||||
const MODES: [DisplayCurrencyMode, string][] = [
|
||||
["auto", "自动"],
|
||||
["CNY", "人民币 CNY"],
|
||||
["USD", "美元 USD"],
|
||||
];
|
||||
|
||||
export function DisplayCurrency({ port, onChanged }: { port: AgentPort; onChanged: () => void }) {
|
||||
const [state, setState] = useState<DisplayCurrencySettings | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
port.displayCurrency().then(setState).catch(() => setState(null));
|
||||
}, [port]);
|
||||
|
||||
const pick = async (mode: DisplayCurrencyMode) => {
|
||||
if (!state || mode === state.mode) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setState(await port.saveDisplayCurrency(mode));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group id="currency" title={t("费用显示币种")}
|
||||
hint={t("只决定费用以哪种货币显示,不改动各来源的价目表。「自动」跟随账户余额的币种,没有余额信息时按价目表的币种显示。")}>
|
||||
{!state ? (
|
||||
<div className="empty">{t("无法读取币种设置。")}</div>
|
||||
) : (
|
||||
<div className="box">
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("显示币种")}</span>
|
||||
<span className="ds">{t("当前会话的费用、余额与用量页立即按所选币种显示,无需重启。")}</span>
|
||||
</span>
|
||||
<div className="seg" data-text role="group" aria-label={t("显示币种")}>
|
||||
{MODES.map(([m, label]) => (
|
||||
<button key={m} data-action="currency.mode" data-value={m} aria-pressed={state.mode === m} disabled={busy} onClick={() => void pick(m)}>
|
||||
{t(label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,35 @@ it("keeps an exact unlisted model id and verifies it independently of the catalo
|
||||
}));
|
||||
});
|
||||
|
||||
it("shows the HTTP status and the endpoint's own words when a model check is refused", async () => {
|
||||
const checkProviderModel = vi.fn(async () => ({
|
||||
model: "clef:27b",
|
||||
status: "unknown" as const,
|
||||
reason: "rejected" as const,
|
||||
httpStatus: 400,
|
||||
detail: "clef:27b does not support tools",
|
||||
}));
|
||||
const port = { checkProviderModel } as unknown as Port;
|
||||
const entry: ProviderEntry = {
|
||||
name: "ollama_local",
|
||||
kind: "openai",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
models: ["clef:27b"],
|
||||
default: "clef:27b",
|
||||
hasKey: false,
|
||||
inUse: false,
|
||||
preset: false,
|
||||
};
|
||||
|
||||
render(<EditConn entry={entry} port={port} busy="" setBusy={() => {}} onDone={() => {}} />);
|
||||
const row = screen.getAllByText("clef:27b").map((el) => el.closest(".mline")).find(Boolean) as HTMLElement;
|
||||
await userEvent.click(within(row).getByRole("button", { name: "验证模型 clef:27b" }));
|
||||
|
||||
await waitFor(() => expect(within(row).getByText(/请求被拒绝,尚未确认/)).toBeTruthy());
|
||||
expect(within(row).getByText("HTTP 400")).toBeTruthy();
|
||||
expect(within(row).getByText("clef:27b does not support tools")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves configured models that a refreshed catalog no longer returns", async () => {
|
||||
const port = {
|
||||
checkProvider: vi.fn(async () => ({ ok: true, models: ["deepseek-new"], vision: [] })),
|
||||
@@ -166,3 +195,32 @@ it("refreshes the list when the save landed but the conversation keeps its setti
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/已保存。当前对话还有未结束的工作/)).toBeTruthy();
|
||||
});
|
||||
|
||||
const rejecting = (status: number, code: string, error: string) => {
|
||||
const editProvider = vi.fn(async () => {
|
||||
throw new HttpError(status, error, { code, error });
|
||||
});
|
||||
const entry: ProviderEntry = {
|
||||
name: "rich", kind: "openai", baseUrl: "https://gateway.invalid/v1",
|
||||
models: ["alpha"], default: "alpha", hasKey: true, inUse: true, preset: false, canSetVision: false,
|
||||
};
|
||||
render(<EditConn entry={entry} port={{ editProvider } as unknown as Port} busy="" setBusy={() => {}} onDone={() => {}} onSaved={() => {}} />);
|
||||
return userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
};
|
||||
|
||||
it("says a refused save failed, as an error alert", async () => {
|
||||
await rejecting(400, "provider.endpoint_required", "no endpoint");
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.getAttribute("data-lvl")).toBe("err");
|
||||
expect(within(alert).getByText("保存失败")).toBeTruthy();
|
||||
expect(screen.queryByText("没保存成功")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not call a saved-but-not-applied outcome a failed save", async () => {
|
||||
await rejecting(409, "provider.saved_while_running", "saved");
|
||||
const note = (await screen.findByText("已保存,尚未生效")).closest(".find")!;
|
||||
expect(note.getAttribute("data-lvl")).toBe("warn");
|
||||
expect(note.getAttribute("role")).toBe("status");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText("保存失败")).toBeNull();
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { ProviderCheck, ProviderEntry } from "../port/port";
|
||||
import { clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
|
||||
import { checkedFact, clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
|
||||
import type { Port } from "./Providers";
|
||||
import { SAVED_NOT_APPLIED, reason } from "../i18n/kernel";
|
||||
import { HttpError } from "../port/port";
|
||||
@@ -23,6 +23,7 @@ export function EditConn({
|
||||
const seededModels = [...new Set([...entry.models, ...(initialCheck?.models ?? [])])];
|
||||
const seededVision = [...new Set([...(entry.visionModels ?? []), ...(initialCheck?.vision ?? [])])];
|
||||
const [baseUrl, setBaseUrl] = useState(entry.baseUrl);
|
||||
const [completed, setCompleted] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [models, setModels] = useState<string[]>(seededModels);
|
||||
const [picked, setPicked] = useState<string[]>(entry.models);
|
||||
@@ -34,7 +35,7 @@ export function EditConn({
|
||||
const [diff, setDiff] = useState(() => initialCheck?.ok ? catalogDiff(entry.models, initialCheck.models ?? []) : null);
|
||||
const [checkingModel, setCheckingModel] = useState("");
|
||||
const [def, setDef] = useState(entry.default || entry.models[0] || "");
|
||||
const [err, setErr] = useState("");
|
||||
const [err, setErr] = useState<{ text: string; kind: "refresh" | "save" | "unapplied" } | null>(null);
|
||||
const [more, setMore] = useState(declare);
|
||||
const [win, setWin] = useState(entry.contextWindow ? String(entry.contextWindow) : "");
|
||||
const [maxOut, setMaxOut] = useState(entry.maxOutputTokens ? String(entry.maxOutputTokens) : "");
|
||||
@@ -86,11 +87,14 @@ export function EditConn({
|
||||
// provider with no credential at all, which fails before it reaches the host.
|
||||
const refetch = async () => {
|
||||
setBusy(`refresh:${entry.name}`);
|
||||
setErr("");
|
||||
setErr(null);
|
||||
try {
|
||||
const refreshed = apiKey.trim()
|
||||
? await port.probeProvider(baseUrl.trim(), apiKey.trim())
|
||||
: await port.checkProvider(entry.name);
|
||||
const changed = !!refreshed.baseUrl && refreshed.baseUrl !== baseUrl.trim();
|
||||
if (changed) setBaseUrl(refreshed.baseUrl!);
|
||||
setCompleted(changed ? refreshed.baseUrl! : "");
|
||||
const found = refreshed.models ?? [];
|
||||
if (found.length === 0) throw new Error("这个端点没报出任何聊天模型");
|
||||
const readers = refreshed.vision ?? [];
|
||||
@@ -102,7 +106,7 @@ export function EditConn({
|
||||
setVision((current) => [...new Set([...current, ...readers])]);
|
||||
setVisionSettable((current) => current ? [...new Set([...current, ...readers])] : current);
|
||||
} catch (e) {
|
||||
setErr(reason(e));
|
||||
setErr({ text: reason(e), kind: "refresh" });
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
@@ -125,7 +129,7 @@ export function EditConn({
|
||||
});
|
||||
setFacts((current) => ({
|
||||
...current,
|
||||
[model]: { ...(current[model] ?? { origin: "configured" }), status: got.status, reason: got.reason },
|
||||
[model]: checkedFact(current[model], "configured", got),
|
||||
}));
|
||||
} catch {
|
||||
setFacts((current) => ({
|
||||
@@ -170,7 +174,7 @@ export function EditConn({
|
||||
|
||||
const save = async () => {
|
||||
setBusy(`edit:${entry.name}`);
|
||||
setErr("");
|
||||
setErr(null);
|
||||
try {
|
||||
await port.editProvider({
|
||||
name: entry.name,
|
||||
@@ -191,10 +195,11 @@ export function EditConn({
|
||||
});
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(reason(e));
|
||||
const unapplied = e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "");
|
||||
setErr({ text: reason(e), kind: unapplied ? "unapplied" : "save" });
|
||||
// Saved but not yet applied: the list has to show what is on file while
|
||||
// the form stays open to say why.
|
||||
if (e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "")) onSaved?.();
|
||||
if (unapplied) onSaved?.();
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
@@ -253,6 +258,9 @@ export function EditConn({
|
||||
<p className="mguide">
|
||||
{t("目录只用于发现,不是白名单。未列出的模型会按原始 ID 保存;验证会发送一次最小请求,可能产生少量 Token 费用。")}
|
||||
</p>
|
||||
{completed !== "" && completed === baseUrl.trim() && (
|
||||
<p className="mdiff" role="status">{t("接口地址已补全为 {url}", { url: completed })}</p>
|
||||
)}
|
||||
{diff && (diff.added > 0 || diff.missing > 0) && (
|
||||
<p className="mdiff" role="status">
|
||||
{diff.added > 0 && t("发现 {n} 个新模型。", { n: diff.added })}
|
||||
@@ -370,9 +378,9 @@ export function EditConn({
|
||||
</details>
|
||||
|
||||
{err && (
|
||||
<div className="find" data-lvl="warn">
|
||||
<span className="t">{t("没保存成功")}</span>
|
||||
<span className="why">{err}</span>
|
||||
<div className="find" data-lvl={err.kind === "unapplied" ? "warn" : "err"} role={err.kind === "unapplied" ? "status" : "alert"}>
|
||||
<span className="t">{t(err.kind === "unapplied" ? "已保存,尚未生效" : err.kind === "save" ? "保存失败" : "刷新模型目录失败")}</span>
|
||||
<span className="why">{err.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -393,4 +393,62 @@ describe("dialog", () => {
|
||||
await ready();
|
||||
expect(body().value).toBe("还没写完的话");
|
||||
});
|
||||
|
||||
it("keeps attached screenshots across close and reopen, and drops them once sent", async () => {
|
||||
setup();
|
||||
await fill();
|
||||
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, [png("one.png"), png("two.png")]);
|
||||
await screen.findByAltText("two.png");
|
||||
await userEvent.click(screen.getByRole("button", { name: "移除截图 one.png" }));
|
||||
cleanup();
|
||||
|
||||
const again = setup();
|
||||
await ready();
|
||||
expect(screen.queryByAltText("one.png")).toBeNull();
|
||||
const img = screen.getByAltText("two.png") as HTMLImageElement;
|
||||
expect(img.src).toMatch(/^data:image\/png/);
|
||||
const spy = vi.spyOn(again.port, "sendFeedback");
|
||||
await userEvent.type(name(), "ada");
|
||||
await userEvent.click(send());
|
||||
expect(await screen.findByText("已收到你的反馈")).toBeTruthy();
|
||||
expect(spy.mock.calls[0]![0].images).toHaveLength(1);
|
||||
expect(spy.mock.calls[0]![0].images[0]).toMatchObject({ name: "two.png" });
|
||||
cleanup();
|
||||
|
||||
setup();
|
||||
await ready();
|
||||
expect(document.querySelector(".fbk-shots")).toBeNull();
|
||||
expect(body().value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps screenshots when a send fails, and honours the count limit after restore", async () => {
|
||||
const { port } = setup({ limits: { bodyBytes: 4000, nameChars: 40, contactChars: 80, images: 2, uploadBytes: 100 } as FeedbackEnv["limits"] });
|
||||
port.sendFeedback = vi.fn(async () => { throw new HttpError(500, "boom"); }) as AgentPort["sendFeedback"];
|
||||
await fill();
|
||||
const input = () => document.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
await userEvent.upload(input(), [png("a.png", 10), png("b.png", 10)]);
|
||||
await waitFor(() => expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2));
|
||||
await userEvent.click(send());
|
||||
await screen.findByRole("alert");
|
||||
expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2);
|
||||
cleanup();
|
||||
|
||||
setup({ limits: { bodyBytes: 4000, nameChars: 40, contactChars: 80, images: 2, uploadBytes: 100 } as FeedbackEnv["limits"] });
|
||||
await ready();
|
||||
expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2);
|
||||
fireEvent.change(input(), { target: { files: [png("c.png", 10)] } });
|
||||
expect(await screen.findByText(/c\.png:最多 2 张/)).toBeTruthy();
|
||||
expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("trims restored screenshots that no longer fit the limits", async () => {
|
||||
setup({ limits: { bodyBytes: 4000, nameChars: 40, contactChars: 80, images: 3, uploadBytes: 100 } as FeedbackEnv["limits"] });
|
||||
await ready();
|
||||
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, [png("a.png", 10), png("b.png", 10), png("c.png", 10)]);
|
||||
await waitFor(() => expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(3));
|
||||
cleanup();
|
||||
setup({ limits: { bodyBytes: 4000, nameChars: 40, contactChars: 80, images: 2, uploadBytes: 100 } as FeedbackEnv["limits"] });
|
||||
await ready();
|
||||
await waitFor(() => expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2));
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
const [body, setBody] = useState(() => heldDraft().body);
|
||||
const [contact, setContact] = useState(() => heldDraft().contact);
|
||||
const [name, setName] = useState("");
|
||||
const [shots, setShots] = useState<Shot[]>([]);
|
||||
const [shots, setShots] = useState<Shot[]>(() => heldDraft().shots);
|
||||
const [refused, setRefused] = useState<Refused[]>([]);
|
||||
const [over, setOver] = useState(false);
|
||||
const [phase, setPhase] = useState<"editing" | "sending" | "sent">("editing");
|
||||
@@ -78,8 +78,8 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
useEffect(load, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "sent") holdDraft({ category, body, contact });
|
||||
}, [category, body, contact, phase]);
|
||||
if (phase !== "sent") holdDraft({ category, body, contact, shots });
|
||||
}, [category, body, contact, shots, phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "sent") done.current?.focus();
|
||||
@@ -91,6 +91,14 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
|
||||
const limits = env?.limits;
|
||||
|
||||
useEffect(() => {
|
||||
if (!limits) return;
|
||||
setShots((prev) => {
|
||||
const fit = prev.filter((s) => s.bytes <= limits.uploadBytes).slice(0, limits.images);
|
||||
return fit.length === prev.length ? prev : fit;
|
||||
});
|
||||
}, [limits]);
|
||||
|
||||
const add = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (!limits || files.length === 0) return;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Market } from "./Market";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
});
|
||||
|
||||
const group = (name: string) => within(screen.getByRole("radiogroup", { name: t(name) }));
|
||||
const radio = (groupName: string, name: string) => group(groupName).getByRole<HTMLButtonElement>("radio", { name: t(name) });
|
||||
|
||||
async function draw(lang = "zh") {
|
||||
localStorage.setItem(STORAGE, lang);
|
||||
boot();
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const read = vi.spyOn(port, "marketList");
|
||||
render(<Market port={port} onInstalled={() => {}} />);
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
|
||||
return { port, read };
|
||||
}
|
||||
|
||||
it.each(["zh", "en"])("sends the kind selected with the keyboard to the registry (%s)", async (lang) => {
|
||||
const { read } = await draw(lang);
|
||||
await userEvent.tab();
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(radio("类型", "全部"));
|
||||
for (const [key, name, kind] of [["ArrowRight", "技能", "skill"], ["ArrowRight", "插件", "plugin"]]) {
|
||||
await userEvent.keyboard("{" + key + "}");
|
||||
expect(document.activeElement).toBe(radio("类型", name));
|
||||
expect(radio("类型", name).getAttribute("aria-checked")).toBe("true");
|
||||
expect(group("类型").getAllByRole("radio").filter((el) => el.getAttribute("aria-checked") === "true")).toHaveLength(1);
|
||||
await waitFor(() => expect(read).toHaveBeenLastCalledWith({ kind, sort: "recommended", q: "", offset: 0, pinned: true }));
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["zh", "en"])("sends the sort selected with the keyboard to the registry (%s)", async (lang) => {
|
||||
const { read } = await draw(lang);
|
||||
radio("排序", "推荐").focus();
|
||||
for (const [key, name, sort] of [["ArrowRight", "近期热门", "trending"], ["ArrowRight", "安装最多", "installs"]]) {
|
||||
await userEvent.keyboard("{" + key + "}");
|
||||
expect(document.activeElement).toBe(radio("排序", name));
|
||||
expect(radio("排序", name).getAttribute("aria-checked")).toBe("true");
|
||||
await waitFor(() => expect(read).toHaveBeenLastCalledWith({ kind: "", sort, q: "", offset: 0, pinned: true }));
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["zh", "en"])("gives each filter group one Tab stop after pointer selection (%s)", async (lang) => {
|
||||
await draw(lang);
|
||||
await userEvent.click(radio("类型", "插件"));
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(radio("排序", "推荐"));
|
||||
await userEvent.tab({ shift: true });
|
||||
expect(document.activeElement).toBe(radio("类型", "插件"));
|
||||
await userEvent.click(radio("排序", "最新"));
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(screen.getByRole("checkbox", { name: t("只看已固定") }));
|
||||
await userEvent.tab({ shift: true });
|
||||
expect(document.activeElement).toBe(radio("排序", "最新"));
|
||||
});
|
||||
|
||||
it("filters and opens a package using only the keyboard without installing it", async () => {
|
||||
const { port, read } = await draw();
|
||||
const detail = vi.spyOn(port, "marketDetail");
|
||||
const install = vi.spyOn(port, "installMarket");
|
||||
await userEvent.tab();
|
||||
await userEvent.keyboard("review");
|
||||
await waitFor(() => expect(read).toHaveBeenLastCalledWith(expect.objectContaining({ q: "review" })));
|
||||
await userEvent.tab();
|
||||
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(radio("排序", "推荐"));
|
||||
await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}");
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(screen.getByRole("checkbox", { name: t("只看已固定") }));
|
||||
await userEvent.keyboard(" ");
|
||||
await waitFor(() => expect(read).toHaveBeenLastCalledWith({ kind: "plugin", sort: "new", q: "review", offset: 0, pinned: false }));
|
||||
await screen.findByRole("button", { name: /review-kit/ });
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: /review-kit/ }));
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await waitFor(() => expect(detail).toHaveBeenCalledExactlyOnceWith("acme/review-kit"));
|
||||
await screen.findByRole("button", { name: t("查看将安装的内容") });
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves pointer and Space selection", async () => {
|
||||
const { read } = await draw();
|
||||
await userEvent.click(radio("类型", "主题"));
|
||||
radio("排序", "最新").focus();
|
||||
await userEvent.keyboard(" ");
|
||||
await waitFor(() => expect(read).toHaveBeenLastCalledWith({ kind: "theme", sort: "new", q: "", offset: 0, pinned: true }));
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { Group } from "./Group";
|
||||
import { PlanConfirm } from "./MarketConfirm";
|
||||
import { MyPackages, PublishForm } from "./MarketPublish";
|
||||
import { MarketVote, approvalLabel } from "./MarketVote";
|
||||
import { arrowTabs } from "./tablist";
|
||||
import { arrowRadios, arrowTabs } from "./tablist";
|
||||
|
||||
const KINDS: [MarketKind | "", string][] = [["", "全部"], ["skill", "技能"], ["plugin", "插件"], ["mcp", "MCP 服务"], ["theme", "主题"]];
|
||||
type Sort = "recommended" | "trending" | "installs" | "new";
|
||||
@@ -121,16 +121,16 @@ export function Market({ port, onInstalled, onViewInstalled, onSignIn }: Props)
|
||||
aria-label={t("搜索社区市场")}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("类型")}>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("类型")} data-action-keydown="market.kind" onKeyDown={arrowRadios}>
|
||||
{KINDS.map(([id, name]) => (
|
||||
<button key={id || "all"} role="radio" aria-checked={kind === id} data-action="market.kind" data-value={id || "all"} onClick={() => setKind(id)}>
|
||||
<button key={id || "all"} role="radio" aria-checked={kind === id} tabIndex={kind === id ? 0 : -1} data-action="market.kind" data-value={id || "all"} onClick={() => setKind(id)}>
|
||||
{t(name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("排序")}>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("排序")} data-action-keydown="market.sort" onKeyDown={arrowRadios}>
|
||||
{SORTS.map(([id, name]) => (
|
||||
<button key={id} role="radio" aria-checked={sort === id} data-action="market.sort" data-value={id} onClick={() => setSort(id)}>
|
||||
<button key={id} role="radio" aria-checked={sort === id} tabIndex={sort === id ? 0 : -1} data-action="market.sort" data-value={id} onClick={() => setSort(id)}>
|
||||
{t(name)}
|
||||
</button>
|
||||
))}
|
||||
@@ -215,7 +215,9 @@ function Entry({ port, slug, onBack, onInstalled, onViewInstalled, onSignIn }: {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setPlan(await port.planMarket({ slug, replace: update, ...(trust ? { trust } : {}) }));
|
||||
const p = await port.planMarket({ slug, replace: update, ...(trust ? { trust } : {}) });
|
||||
if (!p.ok) setError(p.error || p.next || t("该来源中没有可安装的内容"));
|
||||
else setPlan(p);
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
@@ -352,7 +354,7 @@ function Entry({ port, slug, onBack, onInstalled, onViewInstalled, onSignIn }: {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
{error && <div className="why" role="alert">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -378,9 +380,9 @@ export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSig
|
||||
<Group id="market" title={t("社区市场")}
|
||||
hint={t("社区发布、经过审核的技能、插件、MCP 服务与主题。固定了审核内容的版本按审核时的内容安装,未固定的需要你信任发布者;安装前都会列出将写入的全部内容,与粘贴地址安装走同一套确认。")}>
|
||||
{handle ? (
|
||||
<div className="seg mkt-views" data-text role="radiogroup" aria-label={t("社区市场")}>
|
||||
<div className="seg mkt-views" data-text role="radiogroup" aria-label={t("社区市场")} data-action-keydown="market.view" onKeyDown={arrowRadios}>
|
||||
{VIEWS.map(([id, name]) => (
|
||||
<button key={id} role="radio" aria-checked={at === id} disabled={applying} data-action="market.view" data-value={id} onClick={() => { if (id === "publish" && at !== "publish") setDraft(null); setView(id); }}>
|
||||
<button key={id} role="radio" aria-checked={at === id} tabIndex={at === id ? 0 : -1} disabled={applying} data-action="market.view" data-value={id} onClick={() => { if (id === "publish" && at !== "publish") setDraft(null); setView(id); }}>
|
||||
{t(name)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { MarketGroup } from "./Market";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AgentPort, MarketPlan } from "../port/port";
|
||||
import { boot, t } from "../i18n";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.setItem("rx-lang", "zh"); boot(); });
|
||||
|
||||
const account = { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } };
|
||||
const port = () => new MockPort() as unknown as AgentPort;
|
||||
const views = () => within(screen.getByRole("radiogroup", { name: t("社区市场") }));
|
||||
const radio = (name: string) => views().getByRole<HTMLButtonElement>("radio", { name: t(name) });
|
||||
const nameInput = () => screen.getByRole("textbox", { name: new RegExp("^" + t("名称")) });
|
||||
|
||||
describe("market author navigation keys", () => {
|
||||
it.each(["zh", "en"])("keeps one navigation Tab stop and enters the selected view in %s", async (language) => {
|
||||
localStorage.setItem("rx-lang", language); boot();
|
||||
render(<MarketGroup port={port()} account={account} onInstalled={() => {}} onSignIn={() => {}} />);
|
||||
expect(views().getAllByRole<HTMLButtonElement>("radio").filter((button) => button.tabIndex === 0)).toEqual([radio("浏览")]);
|
||||
radio("浏览").focus();
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(screen.getByRole("searchbox"));
|
||||
await userEvent.tab({ shift: true });
|
||||
expect(document.activeElement).toBe(radio("浏览"));
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
expect(views().getAllByRole<HTMLButtonElement>("radio").filter((button) => button.tabIndex === 0)).toEqual([radio("发布")]);
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(screen.getByRole("radio", { name: t("技能") }));
|
||||
await userEvent.tab({ shift: true });
|
||||
expect(document.activeElement).toBe(radio("发布"));
|
||||
});
|
||||
|
||||
it("preserves pointer and Space activation", async () => {
|
||||
render(<MarketGroup port={port()} account={account} onInstalled={() => {}} onSignIn={() => {}} />);
|
||||
await userEvent.click(radio("我的发布"));
|
||||
expect(radio("我的发布").getAttribute("aria-checked")).toBe("true");
|
||||
radio("发布").focus();
|
||||
await userEvent.keyboard(" ");
|
||||
expect(radio("发布").getAttribute("aria-checked")).toBe("true");
|
||||
expect(nameInput()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps applying navigation disabled and restores arrows after the own-install reply", async () => {
|
||||
const backend = port();
|
||||
const plan = await backend.planOwnMarket({ slug: "demo/ship-notes" });
|
||||
let finish!: (result: MarketPlan) => void;
|
||||
const install = vi.spyOn(backend, "installOwnMarket").mockImplementation(() => new Promise((resolve) => { finish = resolve; }));
|
||||
const changed = vi.fn();
|
||||
render(<MarketGroup port={backend} account={account} onInstalled={() => {}} onSignIn={() => {}} onApplying={changed} />);
|
||||
await userEvent.click(radio("我的发布"));
|
||||
const row = (await screen.findByText("ship-notes")).closest("li")!;
|
||||
await userEvent.click(within(row).getByRole("button", { name: "安装" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
expect(changed).toHaveBeenLastCalledWith(true);
|
||||
expect(views().getAllByRole<HTMLButtonElement>("radio").every((button) => button.disabled)).toBe(true);
|
||||
expect(fireEvent.keyDown(radio("我的发布"), { key: "ArrowRight" })).toBe(true);
|
||||
expect(radio("我的发布").getAttribute("aria-checked")).toBe("true");
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finish({ ...plan, applied: true, status: "done" }));
|
||||
expect(changed).toHaveBeenLastCalledWith(false);
|
||||
radio("我的发布").focus();
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(document.activeElement).toBe(radio("发布"));
|
||||
expect(nameInput()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reaches the author form from browse and submits its exact values using only keys", async () => {
|
||||
const backend = port();
|
||||
const mine = vi.spyOn(backend, "myMarket");
|
||||
const publish = vi.spyOn(backend, "publishMarket").mockRejectedValue(new Error("controlled publish refusal"));
|
||||
const install = vi.spyOn(backend, "installMarket");
|
||||
render(<MarketGroup port={backend} account={account} onInstalled={() => {}} onSignIn={() => {}} />);
|
||||
radio("浏览").focus();
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
await screen.findByText("ship-notes");
|
||||
expect(mine).toHaveBeenCalledTimes(1);
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
for (let i = 0; i < 5 && document.activeElement !== nameInput(); i++) await userEvent.tab();
|
||||
expect(document.activeElement).toBe(nameInput());
|
||||
await userEvent.keyboard("key-kit"); await userEvent.tab(); await userEvent.keyboard("1.2.3");
|
||||
await userEvent.tab(); await userEvent.keyboard("https://example.com/SKILL.md");
|
||||
for (let i = 0; i < 6; i++) await userEvent.tab();
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "提交审核" }));
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await screen.findByText("controlled publish refusal");
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(publish).toHaveBeenCalledWith({ kind: "skill", name: "key-kit", source: "https://example.com/SKILL.md", summary: "", description: "", repoUrl: "", version: "1.2.3", tags: [], visibility: "public" });
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the existing sign-in action without author radio controls when signed out", () => {
|
||||
render(<MarketGroup port={port()} account={{ signedIn: false }} onInstalled={() => {}} onSignIn={() => {}} />);
|
||||
expect(screen.queryByRole("radiogroup", { name: "社区市场" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "去登录" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,11 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
|
||||
onApplying?.(false);
|
||||
port
|
||||
.planOwnMarket({ slug: pkg.slug, replace })
|
||||
.then((p) => live() && setPlan(p))
|
||||
.then((p) => {
|
||||
if (!live()) return;
|
||||
if (!p.ok) setError(p.error || p.next || t("该来源中没有可安装的内容"));
|
||||
else setPlan(p);
|
||||
})
|
||||
.catch((e) => live() && setError(reason(e)))
|
||||
.finally(() => live() && setBusy(false));
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { PublishForm } from "./MarketPublish";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
});
|
||||
|
||||
const nameField = () => screen.getByLabelText<HTMLInputElement>(new RegExp("^" + t("名称")));
|
||||
const sourceField = () => screen.getByLabelText<HTMLInputElement>(new RegExp("^" + t("来源地址")));
|
||||
const types = () => within(screen.getByRole("radiogroup", { name: t("类型") }));
|
||||
const radio = (name: string) => types().getByRole<HTMLButtonElement>("radio", { name: t(name) });
|
||||
|
||||
function draw(lang = "zh") {
|
||||
localStorage.setItem(STORAGE, lang);
|
||||
boot();
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
render(<PublishForm port={port} handle="demo" onMine={() => {}} />);
|
||||
return port;
|
||||
}
|
||||
|
||||
it.each(["zh", "en"])("leaves the selected kind in one Tab stop and returns to it (%s)", async (lang) => {
|
||||
draw(lang);
|
||||
await userEvent.click(radio("插件"));
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(nameField());
|
||||
await userEvent.tab({ shift: true });
|
||||
expect(document.activeElement).toBe(radio("插件"));
|
||||
});
|
||||
|
||||
it("publishes the kind chosen entirely with the keyboard", async () => {
|
||||
const port = draw();
|
||||
const publish = vi.spyOn(port, "publishMarket").mockRejectedValue(new Error("registry unavailable"));
|
||||
await userEvent.tab();
|
||||
await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}");
|
||||
expect(document.activeElement).toBe(radio("主题"));
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(nameField());
|
||||
await userEvent.keyboard("notes-kit");
|
||||
await userEvent.tab();
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(sourceField());
|
||||
const source = "https://github.com/demo/notes-kit/tree/" + "a".repeat(40) + "/theme";
|
||||
await userEvent.keyboard(source);
|
||||
for (let i = 0; i < 6; i++) await userEvent.tab();
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: t("提交审核") }));
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(publish).toHaveBeenCalledExactlyOnceWith({
|
||||
kind: "theme", name: "notes-kit", source, summary: "", description: "", repoUrl: "", version: "", tags: [], visibility: "public",
|
||||
});
|
||||
expect(await screen.findByText("registry unavailable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps mouse and Space selection available", async () => {
|
||||
draw();
|
||||
await userEvent.click(radio("主题"));
|
||||
expect(radio("主题").getAttribute("aria-checked")).toBe("true");
|
||||
radio("MCP 服务").focus();
|
||||
await userEvent.keyboard(" ");
|
||||
expect(radio("MCP 服务").getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort, MarketKind, MarketPackage, MarketPublished } from "../port/port";
|
||||
import { OwnInstall } from "./MarketOwn";
|
||||
import { arrowRadios } from "./tablist";
|
||||
|
||||
const KINDS: [MarketKind, string][] = [["skill", "技能"], ["plugin", "插件"], ["mcp", "MCP 服务"], ["theme", "主题"]];
|
||||
|
||||
@@ -119,9 +120,9 @@ function PublishDraft({ port, handle, onMine, initial }: PublishProps) {
|
||||
<p className="mkt-sum">{t(d.private
|
||||
? "以 @{handle} 的名义保存,仅自己可见,不提交审核。只收来源地址,不上传文件。"
|
||||
: "以 @{handle} 的名义提交,审核通过后公开。只收来源地址,不上传文件。", { handle })}</p>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("类型")}>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("类型")} data-action-keydown="market.draft" data-value="kind" onKeyDown={arrowRadios}>
|
||||
{KINDS.map(([id, name]) => (
|
||||
<button key={id} type="button" role="radio" aria-checked={d.kind === id} disabled={busy} data-action="market.draft" data-value="kind" onClick={() => setD({ ...d, kind: id })}>
|
||||
<button key={id} type="button" role="radio" aria-checked={d.kind === id} tabIndex={d.kind === id ? 0 : -1} disabled={busy} data-action="market.draft" data-value="kind" onClick={() => setD({ ...d, kind: id })}>
|
||||
{t(name)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { ProviderModelCheckReason, ProviderModelCheckStatus } from "../port/port";
|
||||
import type { ProviderModelCheck, ProviderModelCheckReason, ProviderModelCheckStatus } from "../port/port";
|
||||
|
||||
// Which models a connection offers. A gateway answers with hundreds of names
|
||||
// that differ by a date suffix, so the list is a search field first: one field
|
||||
@@ -18,9 +18,15 @@ export interface ModelFact {
|
||||
origin: ModelOrigin;
|
||||
status?: ProviderModelCheckStatus;
|
||||
reason?: ProviderModelCheckReason;
|
||||
httpStatus?: number;
|
||||
detail?: string;
|
||||
checking?: boolean;
|
||||
}
|
||||
|
||||
export function checkedFact(fact: ModelFact | undefined, origin: ModelOrigin, got: ProviderModelCheck): ModelFact {
|
||||
return { ...(fact ?? { origin }), status: got.status, reason: got.reason, httpStatus: got.httpStatus, detail: got.detail };
|
||||
}
|
||||
|
||||
export function clearModelCheckFacts(facts: Record<string, ModelFact>): Record<string, ModelFact> {
|
||||
return Object.fromEntries(Object.entries(facts).map(([model, fact]) => [
|
||||
model,
|
||||
@@ -176,6 +182,8 @@ function ModelEvidence({ fact }: { fact: ModelFact }) {
|
||||
<span className="mevidence" role={fact.status === "unavailable" ? "alert" : "status"} aria-live="polite">
|
||||
<span>{t(origin)}</span>
|
||||
{state && <><i aria-hidden="true" data-state={fact.checking ? "checking" : fact.status ?? "unverified"} />{t(state)}</>}
|
||||
{!fact.checking && fact.httpStatus ? <code className="mhttp">{`HTTP ${fact.httpStatus}`}</code> : null}
|
||||
{!fact.checking && fact.detail ? <span className="mdetail">{fact.detail}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ function failed(e: unknown): Failed {
|
||||
export function Onboarding({ port, setup, onDone }: Props) {
|
||||
const [pick, setPick] = useState(SHORTCUTS[0].label);
|
||||
const [url, setUrl] = useState(SHORTCUTS[0].url);
|
||||
const [completed, setCompleted] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<Failed | null>(setup.error ? { text: setup.error } : null);
|
||||
@@ -111,6 +112,9 @@ export function Onboarding({ port, setup, onDone }: Props) {
|
||||
try {
|
||||
const probe = await port.probeProvider(at, k);
|
||||
setFound(probe);
|
||||
const changed = !!probe.baseUrl && probe.baseUrl !== at;
|
||||
if (changed) setUrl(probe.baseUrl!);
|
||||
setCompleted(changed ? probe.baseUrl! : "");
|
||||
setKind(probe.kind);
|
||||
setModel(probe.default || probe.models[0] || "");
|
||||
} catch (e) {
|
||||
@@ -139,7 +143,7 @@ export function Onboarding({ port, setup, onDone }: Props) {
|
||||
vision: found.vision,
|
||||
replace: true,
|
||||
});
|
||||
await port.setModel(`${name}/${model}`);
|
||||
await port.setModel(`${name}/${model}`, true);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(failed(e));
|
||||
@@ -260,6 +264,9 @@ export function Onboarding({ port, setup, onDone }: Props) {
|
||||
<span>{t("连上了")}</span>
|
||||
<span className="k">{t("{n} 个模型 · key 保存在本机", { n: found.models.length })}</span>
|
||||
</div>
|
||||
{completed !== "" && completed === url.trim() && (
|
||||
<p className="probe-ok" role="status">{t("接口地址已补全为 {url}", { url: completed })}</p>
|
||||
)}
|
||||
<div className="onb-field">
|
||||
<span className="lb">{t("接入方式")}</span>
|
||||
<div className="onb-protocols" role="radiogroup" aria-label={t("接入方式")}>
|
||||
|
||||
Loaded 100 of 586 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user