mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge studio into feat/13111-origin-ask
This commit is contained in:
commit
ed1dfebb9e
1110 files changed
+48496
-3228
No files matched your search
@@ -5,6 +5,7 @@
|
||||
*.bat text eol=crlf
|
||||
*.css text eol=lf
|
||||
*.go text eol=lf
|
||||
*.golden text eol=lf
|
||||
*.html text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: acp-conformance
|
||||
|
||||
# The agent is driven over stdio by the official ACP TypeScript SDK, and every
|
||||
# message it writes is held to the schema that SDK ships. Not a required check:
|
||||
# it runs when the ACP frontend or the harness changes, and on request.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main-v2, studio]
|
||||
paths:
|
||||
- "internal/frontend/acp/**"
|
||||
- "tools/acp-conformance/**"
|
||||
- ".github/workflows/acp-conformance.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: acp-conformance-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
conformance:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: make acp-conformance
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
if: matrix.branch != ''
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
|
||||
- name: Perform CodeQL Analysis
|
||||
if: matrix.branch == ''
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# Results from a checked-out line are filed under that line's ref, not the
|
||||
# default branch the schedule happened to run from.
|
||||
- name: Perform CodeQL Analysis (${{ matrix.branch }})
|
||||
if: matrix.branch != ''
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
ref: refs/heads/${{ matrix.branch }}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Deploy website
|
||||
|
||||
# GitHub requires workflow_dispatch to be registered on the default branch.
|
||||
# The production implementation is the same workflow path on website.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_version:
|
||||
description: "Optional product release version associated with this publication"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_request:
|
||||
description: "Optional release workflow correlation ID"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
studio_version:
|
||||
description: "Studio version that must be present in the generated changelog"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
require-website:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo "::error::Deploy the website with --ref website; production is restricted to that branch."
|
||||
exit 1
|
||||
@@ -389,6 +389,14 @@ jobs:
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
|
||||
|
||||
- name: Verify the AppImage
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
arch="${platform#linux/}"
|
||||
bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-$arch.AppImage" "$arch"
|
||||
|
||||
# With signing on, this leg only hands the bundle on: the signing jobs
|
||||
# run nothing but the signer, and windows-package packages in between.
|
||||
- name: Upload the unsigned Windows bundle for signing
|
||||
@@ -1199,7 +1207,7 @@ jobs:
|
||||
fi
|
||||
{
|
||||
printf '%s\n\n' '---'
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。\n\n" "$windows"
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。也提供 \`.AppImage\`(只提供下载,不会自更新;需要 FUSE 2,或用 \`--appimage-extract-and-run\` 启动)。\n\n" "$windows"
|
||||
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
|
||||
} >> "$notes"
|
||||
assets=()
|
||||
@@ -1294,6 +1302,25 @@ jobs:
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog -> $VERSION"
|
||||
|
||||
website:
|
||||
name: publish the Studio website changelog
|
||||
needs: [resolve, publish]
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix' && needs.resolve.outputs.candidate != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
- name: Deploy and verify the website changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: bash scripts/publish-studio-changelog.sh
|
||||
|
||||
# A CLI channel that moves while 1.x still publishes would have two lines
|
||||
# writing it, so every CLI job waits for this check. It runs no repository code.
|
||||
cli-gate:
|
||||
|
||||
@@ -144,9 +144,12 @@ jobs:
|
||||
node perf/jump-ends.mjs
|
||||
node perf/modelrows.mjs
|
||||
node perf/explorersize.mjs
|
||||
node perf/git-panel.mjs
|
||||
node perf/session-move-to.mjs
|
||||
node perf/shell-refused.mjs
|
||||
node perf/external-origin.mjs
|
||||
node perf/rail-search-hold.mjs
|
||||
node perf/rail-toggle-panes.mjs
|
||||
node perf/session-move.mjs
|
||||
node perf/session-ref.mjs
|
||||
node perf/browser-full.mjs
|
||||
@@ -175,6 +178,11 @@ jobs:
|
||||
node perf/mcp-actions.mjs
|
||||
node perf/web-access.mjs
|
||||
node perf/peers-settings.mjs
|
||||
node perf/output-cap.mjs
|
||||
node perf/progress-tail.mjs
|
||||
node perf/trust-wait.mjs
|
||||
node perf/nest-nodes.mjs
|
||||
node perf/provider-ports.mjs
|
||||
node perf/peer-threads.mjs
|
||||
node perf/text-size.mjs
|
||||
node perf/fs-scale.mjs
|
||||
@@ -364,7 +372,7 @@ jobs:
|
||||
arm64=false
|
||||
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
|
||||
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-linux-appimage|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
arm64=true
|
||||
fi
|
||||
fi
|
||||
@@ -608,6 +616,10 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
|
||||
|
||||
- name: The AppImage carries its architecture, the kernel and the page
|
||||
if: runner.os == 'Linux'
|
||||
run: bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.AppImage" "${{ matrix.arch }}"
|
||||
|
||||
# Recorded, not asserted: the real bundle layout, so later work argues
|
||||
# from what electron-builder produced rather than from its documentation.
|
||||
- name: Record the macOS bundle layout
|
||||
|
||||
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
|
||||
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
|
||||
GOLANGCI_VERSION := $(shell cat .golangci-version)
|
||||
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck acp-conformance check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -87,6 +87,13 @@ lint-cross:
|
||||
test: studio-test sdk-test
|
||||
go test ./...
|
||||
|
||||
# Drives `reasonix acp` with the official ACP TypeScript SDK and holds every
|
||||
# message it writes to the schema that SDK ships. Needs node and network for
|
||||
# `npm ci`, which runs without install scripts against a lockfile.
|
||||
acp-conformance: build
|
||||
cd tools/acp-conformance && npm ci --ignore-scripts --no-audit --no-fund
|
||||
cd tools/acp-conformance && REASONIX_BIN=$(CURDIR)/bin/reasonix$(GOEXE) npm test
|
||||
|
||||
# The shell is a nested module, so the root `test` target never reaches it.
|
||||
studio-test:
|
||||
cd desktop && go test ./...
|
||||
|
||||
@@ -93,6 +93,16 @@ Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setu
|
||||
|
||||
All four are the same program underneath. What follows is for advanced users and contributors.
|
||||
|
||||
## Use it from DeepSeek Harness
|
||||
|
||||
DeepSeek Harness (`dsh`) can hand a task to Reasonix as a subagent over ACP. This repository publishes a plugin index for it:
|
||||
|
||||
1. Run `reasonix setup` on the machine that runs `dsh`. In the DSH Web UI turn on Custom plugin markets (Plugins, More) and add this index in the Discover tab: `https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
|
||||
2. Install "Reasonix subagent" from it.
|
||||
3. Open the plugin's page and turn its switch on. While it is off the model has no `reasonix` tool and may run `reasonix -p` in its own shell, which looks like a delegation and is not one.
|
||||
|
||||
Setup details, the permission policy and limits are in [Using Reasonix from DeepSeek Harness](./docs/DSH.md).
|
||||
|
||||
## Versions
|
||||
|
||||
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
|
||||
@@ -151,7 +161,7 @@ pre-releases):
|
||||
| --- | --- | --- |
|
||||
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
|
||||
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
|
||||
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
|
||||
| Linux | `.deb`, `.rpm`, or `.AppImage` (download only) | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
|
||||
|
||||
Every package ships with a `.minisig` signature, and the release carries
|
||||
`SHA256SUMS`. Once installed, Studio updates itself in place.
|
||||
@@ -240,12 +250,14 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
|
||||
## Documentation
|
||||
|
||||
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
|
||||
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
|
||||
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md) ·
|
||||
[Use from DeepSeek Harness](./docs/DSH.md)
|
||||
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
|
||||
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[Recovery and updates](./docs/RECOVERY.md) ·
|
||||
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
|
||||
- **Agent presets:** [Session Agent compositions](./docs/AGENT_COMPOSITIONS.md)
|
||||
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
|
||||
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
|
||||
[Tool contract](./docs/TOOL_CONTRACT.md) · [Moving from 1.x to 2.x](./docs/MIGRATING.md) ·
|
||||
@@ -277,10 +289,10 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
|
||||
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
|
||||
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
|
||||
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
+17
-6
@@ -91,6 +91,16 @@ Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI
|
||||
|
||||
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
|
||||
|
||||
## 在 DeepSeek Harness 里使用
|
||||
|
||||
DeepSeek Harness(`dsh`)可以通过 ACP 把任务交给 Reasonix 作为子智能体去做。本仓库为此发布了一个插件索引:
|
||||
|
||||
1. 在运行 `dsh` 的机器上先运行 `reasonix setup`。在 DSH 网页界面打开“自定义插件市场”(插件,更多),再到 Discover 页添加这个索引:`https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
|
||||
2. 从中安装“Reasonix subagent”。
|
||||
3. 打开这个插件的页面,把它的开关打开。关着的时候模型没有 `reasonix` 工具,可能改在自己的 shell 里直接运行 `reasonix -p`,看起来像委派,其实不是。
|
||||
|
||||
配置细节、权限策略和限制见 [在 DeepSeek Harness 中使用 Reasonix](./docs/DSH.md)。
|
||||
|
||||
## 版本
|
||||
|
||||
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
|
||||
@@ -139,7 +149,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
| --- | --- | --- |
|
||||
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
|
||||
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
|
||||
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
|
||||
| Linux | `.deb`、`.rpm` 或 `.AppImage`(只提供下载) | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
|
||||
|
||||
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
|
||||
应用内自行更新。
|
||||
@@ -221,12 +231,13 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
|
||||
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
|
||||
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.md) ·
|
||||
[ACP 编辑器接入](./docs/ACP.md)
|
||||
[ACP 编辑器接入](./docs/ACP.md) · [在 DeepSeek Harness 中使用](./docs/DSH.md)
|
||||
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.md) ·
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
|
||||
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[恢复与安全模式](./docs/RECOVERY.md) ·
|
||||
[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)
|
||||
- **Agent 预设:** [会话级组合](./docs/AGENT_COMPOSITIONS.zh.md)
|
||||
- **工程与迁移:** [规格](./docs/SPEC.md) ·
|
||||
[任务合约与暂停策略](./docs/TASK_CONTRACT.md) ·
|
||||
[工具合约](./docs/TOOL_CONTRACT.md) ·
|
||||
@@ -259,10 +270,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
|
||||
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
|
||||
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
|
||||
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
@@ -373,6 +373,10 @@ out of how a path is spelled, which cannot tell `internal/auth` from
|
||||
- sensitive: internal/safety/egress/**
|
||||
- sensitive: internal/platform/browser/**
|
||||
- sensitive: internal/platform/computer/**
|
||||
- sensitive: internal/platform/gitpush/**
|
||||
- sensitive: internal/session/control/git_push.go
|
||||
- sensitive: internal/frontend/serve/git_push.go
|
||||
- sensitive: internal/frontend/serve/host_routes.go
|
||||
|
||||
One declaration, two effects: the same list is also the coverage gate's subject.
|
||||
`make coverage-gate` holds each path to the coverage it already has, because
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
read, err := auditAnswerTrajectories(dir, task{ID: "demo", answerRoot: root}, 2)
|
||||
read, err := auditAnswerTrajectories(dir, "demo", task{ID: "demo", answerRoot: root}, 2)
|
||||
if err != nil || !read {
|
||||
t.Fatalf("earlier answer read was missed: read=%t err=%v", read, err)
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func scanAnswerRead(path string, bodyLines []string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
|
||||
func auditAnswerTrajectories(dir, stem string, t task, segments int) (bool, error) {
|
||||
if segments < 1 {
|
||||
return false, fmt.Errorf("run recorded no segments")
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
for index := 1; index <= segments; index++ {
|
||||
path := segmentTrajectoryPath(dir, t.ID, segment{index: index}, segments)
|
||||
path := segmentTrajectoryPath(dir, stem, segment{index: index}, segments)
|
||||
read, err := scanAnswerRead(path, lines)
|
||||
if err != nil || read {
|
||||
return read, err
|
||||
@@ -170,11 +170,11 @@ func answerTrajectoryDir(dir string, required bool) (string, func(), error) {
|
||||
return dir, cleanup, nil
|
||||
}
|
||||
|
||||
func auditAnswerRun(r *result, dir string, t task) bool {
|
||||
func auditAnswerRun(r *result, dir, stem string, t task) bool {
|
||||
if t.answerRoot == "" {
|
||||
return true
|
||||
}
|
||||
read, err := auditAnswerTrajectories(dir, t, r.Segments)
|
||||
read, err := auditAnswerTrajectories(dir, stem, t, r.Segments)
|
||||
if err != nil {
|
||||
markAnswerAuditIncomplete(r, err)
|
||||
r.Note = "answer audit: " + err.Error()
|
||||
|
||||
+13
-10
@@ -41,19 +41,21 @@ func requestsBySourceLine(bySource map[string]sourceUsage) string {
|
||||
return "**Requests by source:** " + strings.Join(parts, " · ") + "\n\n"
|
||||
}
|
||||
|
||||
// armStats is one arm's aggregate over a -json report, using the same
|
||||
// accounting conventions as renderBody: spend totals cover accounted runs
|
||||
// (failures included) and per-solved figures divide by accounted solves.
|
||||
// armStats is one arm's aggregate over a -json report: spend totals and
|
||||
// per-solved figures cover accounted runs (failures included), while wall
|
||||
// rates (Solved/hour, wall/task) cover every run.
|
||||
type armStats struct {
|
||||
Ran, Pass1, Solved, AccountedSolved int
|
||||
Steps, Tools, Rounds, PlannerCalls int
|
||||
Tokens, Hit, Miss int
|
||||
Cost float64
|
||||
WallMs int64
|
||||
FirstHit, FirstMiss int64
|
||||
Damaged, WithCorrect int
|
||||
TTCS, TTFT []int64
|
||||
ByClass map[string]classStats
|
||||
// WallMs is all-run wall (a killed run's wall was still spent);
|
||||
// AccountedWallMs is the accounted subset the per-solved wall cost divides.
|
||||
WallMs, AccountedWallMs int64
|
||||
FirstHit, FirstMiss int64
|
||||
Damaged, WithCorrect int
|
||||
TTCS, TTFT []int64
|
||||
ByClass map[string]classStats
|
||||
// Fanout covers every run in the arm, no-solution tasks included: work the
|
||||
// agent delegated is work it paid for whether or not the task was solvable.
|
||||
Fanout fanoutMetrics
|
||||
@@ -107,6 +109,7 @@ func aggregateArm(results []result) armStats {
|
||||
}
|
||||
c.WallMs += r.WallMs
|
||||
s.ByClass[label] = c
|
||||
s.WallMs += r.WallMs
|
||||
if r.Unaccounted {
|
||||
continue
|
||||
}
|
||||
@@ -119,7 +122,7 @@ func aggregateArm(results []result) armStats {
|
||||
s.Hit += r.CacheHitTokens
|
||||
s.Miss += r.CacheMissTokens
|
||||
s.Cost += r.Cost
|
||||
s.WallMs += r.WallMs
|
||||
s.AccountedWallMs += r.WallMs
|
||||
s.PlannerCalls += r.UsageBySource["planner"].Calls
|
||||
if r.Trajectory != nil {
|
||||
s.Rounds += r.Trajectory.ModelRounds
|
||||
@@ -309,7 +312,7 @@ func compareReports(pathA, pathB string) (string, error) {
|
||||
fmt.Fprintf(&b, "| Model rounds / solved | %s | %s |\n", perSolved(float64(a.Rounds), a.AccountedSolved), perSolved(float64(bStats.Rounds), bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Tool calls / solved | %s | %s |\n", perSolved(float64(a.Tools), a.AccountedSolved), perSolved(float64(bStats.Tools), bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Tokens / solved | %s | %s |\n", perSolved(float64(a.Tokens), a.AccountedSolved), perSolved(float64(bStats.Tokens), bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Wall seconds / solved | %s | %s |\n", perSolved(float64(a.WallMs)/1000, a.AccountedSolved), perSolved(float64(bStats.WallMs)/1000, bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Wall seconds / solved | %s | %s |\n", perSolved(float64(a.AccountedWallMs)/1000, a.AccountedSolved), perSolved(float64(bStats.AccountedWallMs)/1000, bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Cost / solved | %s | %s |\n", perSolved(a.Cost, a.AccountedSolved), perSolved(bStats.Cost, bStats.AccountedSolved))
|
||||
b.WriteString(marginalUtilitySection(a, bStats))
|
||||
b.WriteString(memoryUtilitySection(pathA, pathB))
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
// Wall population rule: a rate figure divides every run's wall (a killed run
|
||||
// still spent it); a per-solved cost divides the accounted wall of solved runs.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// reportSolvedPerHour reads the "Solved/hour" number the suite report prints.
|
||||
func reportSolvedPerHour(t *testing.T, report string) string {
|
||||
t.Helper()
|
||||
m := regexp.MustCompile(`\*\*Solved/hour\*\* ([\d.]+)`).FindStringSubmatch(report)
|
||||
if m == nil {
|
||||
t.Fatalf("suite report has no Solved/hour line:\n%s", report)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// compareSolvedPerHour reads the Solved/hour column out of the compare table's
|
||||
// one arm row.
|
||||
func compareSolvedPerHour(t *testing.T, report string) string {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(report, "\n") {
|
||||
if !strings.HasPrefix(line, "| `arm` |") {
|
||||
continue
|
||||
}
|
||||
cells := strings.Split(line, "|")
|
||||
if len(cells) < 8 {
|
||||
t.Fatalf("compare row has %d cells:\n%s", len(cells), line)
|
||||
}
|
||||
return strings.TrimSpace(cells[7])
|
||||
}
|
||||
t.Fatalf("compare table has no arm row:\n%s", report)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Solved/hour is a rate, not a per-solved figure: the suite report divides every
|
||||
// run's wall (a killed run's tokens are unknown, its wall was still spent), so
|
||||
// aggregateArm must not drop the unaccounted run's wall from that denominator.
|
||||
func TestCompareSolvedPerHourUsesTheSameWallAsTheReport(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
results := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
|
||||
}
|
||||
path := writeArm(t, dir, "arm.json", results)
|
||||
|
||||
want := reportSolvedPerHour(t, renderBody(results))
|
||||
|
||||
report, err := multiCompareReport([]string{path, path})
|
||||
if err != nil {
|
||||
t.Fatalf("multiCompareReport: %v", err)
|
||||
}
|
||||
if got := compareSolvedPerHour(t, report); got != want {
|
||||
t.Fatalf("compare Solved/hour = %s, suite report says %s for the same artifact\n"+
|
||||
"1600s of known wall (one 30-minute killed run) went missing from the compare denominator:\n%s",
|
||||
got, want, report)
|
||||
}
|
||||
}
|
||||
|
||||
// Same root cause, second printed number: the per-class table counts every
|
||||
// run's wall (c.WallMs is accumulated before the Unaccounted continue) while the
|
||||
// global marginal-utility line divided only accounted wall by the same task
|
||||
// denominator. One section, two populations: the class row says A cost +870.0s
|
||||
// per task more, the global line said -30.0s less.
|
||||
func TestMarginalUtilityGlobalWallMatchesThePerClassRows(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
mk := func(id string, wallMs int64, unaccounted bool) result {
|
||||
r := result{task: task{ID: id, Class: "bugfix"}, Passed: true, WallMs: wallMs}
|
||||
r.Unaccounted = unaccounted
|
||||
return r
|
||||
}
|
||||
a := []result{
|
||||
mk("ok", 60_000, false),
|
||||
mk("killed", 1_800_000, true),
|
||||
}
|
||||
b := []result{
|
||||
mk("ok", 60_000, false),
|
||||
mk("killed2", 60_000, false),
|
||||
}
|
||||
pathA := writeArm(t, dir, "a.json", a)
|
||||
pathB := writeArm(t, dir, "b.json", b)
|
||||
|
||||
report, err := compareReports(pathA, pathB)
|
||||
if err != nil {
|
||||
t.Fatalf("compareReports: %v", err)
|
||||
}
|
||||
var row string
|
||||
for line := range strings.SplitSeq(report, "\n") {
|
||||
if strings.HasPrefix(line, "| bugfix |") {
|
||||
row = line
|
||||
}
|
||||
}
|
||||
if row == "" {
|
||||
t.Fatalf("no bugfix class row:\n%s", report)
|
||||
}
|
||||
global := regexp.MustCompile(`wall/task ([+-][\d.]+)s`).FindStringSubmatch(report)
|
||||
if global == nil {
|
||||
t.Fatalf("no global wall/task line:\n%s", report)
|
||||
}
|
||||
// The one class row reports A 930.0s/task, B 60.0s/task, i.e. +870.0s.
|
||||
if global[1] != "+870.0" {
|
||||
t.Fatalf("global wall/task = %ss while the per-class row beside it says +870.0s "+
|
||||
"(A 930.0s vs B 60.0s): the global figure drops the unaccounted run's wall, "+
|
||||
"the class row keeps it\n%s\n%s", global[1], row, report)
|
||||
}
|
||||
}
|
||||
|
||||
// compareOutput pins a compare rendering byte for byte. RX_UPDATE_GOLDEN
|
||||
// rewrites it; the no-unaccounted fixture is the population where the two wall
|
||||
// totals coincide, so the readout must not move when only the accounting does.
|
||||
func compareOutput(t *testing.T, dir, name, got string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if os.Getenv("RX_UPDATE_GOLDEN") != "" {
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatalf("write golden: %v", err)
|
||||
}
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
if got != string(want) {
|
||||
t.Fatalf("%s: compare output changed\n--- got ---\n%s\n--- want ---\n%s", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A fixture with no unaccounted runs is where every wall is accounted wall:
|
||||
// both rates must render exactly as they did before the population split.
|
||||
func TestCompareOutputUnchangedWithoutUnaccountedRuns(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
goldenDir, err := filepath.Abs("testdata")
|
||||
if err != nil {
|
||||
t.Fatalf("testdata dir: %v", err)
|
||||
}
|
||||
t.Chdir(dir)
|
||||
a := []result{
|
||||
{task: task{ID: "a", Class: "bugfix"}, Passed: true, WallMs: 60_000, Attempt: 1, TTCSMs: 60_000,
|
||||
runMetrics: runMetrics{Steps: 7, ToolCalls: 10, PromptTokens: 900, CompletionTokens: 100, Cost: 0.02, Currency: "$"}},
|
||||
{task: task{ID: "b", Class: "perf"}, WallMs: 90_000, Attempt: 1,
|
||||
runMetrics: runMetrics{Steps: 9, ToolCalls: 14, PromptTokens: 1200, CompletionTokens: 200, Cost: 0.03, Currency: "$"}},
|
||||
}
|
||||
b := []result{
|
||||
{task: task{ID: "a", Class: "bugfix"}, Passed: true, WallMs: 45_000, Attempt: 1, TTCSMs: 45_000,
|
||||
runMetrics: runMetrics{Steps: 5, ToolCalls: 10, PromptTokens: 700, CompletionTokens: 100, Cost: 0.01, Currency: "$"}},
|
||||
{task: task{ID: "b", Class: "perf"}, WallMs: 90_000, Attempt: 1,
|
||||
runMetrics: runMetrics{Steps: 9, ToolCalls: 14, PromptTokens: 1200, CompletionTokens: 200, Cost: 0.03, Currency: "$"}},
|
||||
}
|
||||
writeArm(t, ".", "a.json", a)
|
||||
writeArm(t, ".", "b.json", b)
|
||||
|
||||
ab, err := compareReports("a.json", "b.json")
|
||||
if err != nil {
|
||||
t.Fatalf("compareReports: %v", err)
|
||||
}
|
||||
compareOutput(t, goldenDir, "no_unaccounted_ab.golden", ab)
|
||||
|
||||
multi, err := multiCompareReport([]string{"a.json", "b.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("multiCompareReport: %v", err)
|
||||
}
|
||||
compareOutput(t, goldenDir, "no_unaccounted_multi.golden", multi)
|
||||
}
|
||||
|
||||
// Wall seconds / solved divides the accounted wall by accounted solves: the
|
||||
// unaccounted run's wall must stay out of that ratio even though it now enters
|
||||
// every rate figure.
|
||||
func TestWallSecondsPerSolvedKeepsAccountedPopulation(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
a := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
|
||||
}
|
||||
b := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "ok2"}, Passed: true, WallMs: 60_000},
|
||||
}
|
||||
report, err := compareReports(writeArm(t, dir, "a.json", a), writeArm(t, dir, "b.json", b))
|
||||
if err != nil {
|
||||
t.Fatalf("compareReports: %v", err)
|
||||
}
|
||||
// A: 60s accounted wall over 1 accounted solve; its all-run wall (1860s)
|
||||
// would print 1860.0 here, or 930.0 over all solves. B has no unaccounted
|
||||
// run, so both walls coincide at 60.0.
|
||||
if !strings.Contains(report, "| Wall seconds / solved | 60.0 | 60.0 |") {
|
||||
t.Fatalf("Wall seconds / solved is not accounted wall / accounted solves:\n%s", report)
|
||||
}
|
||||
for _, wrong := range []string{"| Wall seconds / solved | 1860.0 |", "| Wall seconds / solved | 930.0 |"} {
|
||||
if strings.Contains(report, wrong) {
|
||||
t.Fatalf("Wall seconds / solved divided the all-run wall (%s):\n%s", wrong, report)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The suite report's Solved/hour convention is the reference the compare mode
|
||||
// now follows: every attempt's wall, solved runs over that total.
|
||||
func TestSuiteReportSolvedPerHourCountsEveryRun(t *testing.T) {
|
||||
withKilled := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
|
||||
}
|
||||
if got := reportSolvedPerHour(t, renderBody(withKilled)); got != "3.9" {
|
||||
t.Errorf("suite report Solved/hour = %s, want 3.9 (2 solves over 1860s)", got)
|
||||
}
|
||||
allAccounted := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "ok2"}, Passed: true, WallMs: 60_000},
|
||||
}
|
||||
if got := reportSolvedPerHour(t, renderBody(allAccounted)); got != "60.0" {
|
||||
t.Errorf("suite report Solved/hour = %s, want 60.0 (2 solves over 120s)", got)
|
||||
}
|
||||
}
|
||||
@@ -272,7 +272,7 @@ func main() {
|
||||
ablateFlag := flag.String("ablate", "", "ablation arm: subsystems to switch off ("+ablation.ModuleList()+"; none|all)")
|
||||
foldIndexFlag := flag.String("fold-index", "", "fold-index arm: how much of the model-visible address list to keep (default|half|quarter|off)")
|
||||
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
|
||||
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory")
|
||||
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory (<task-id>.trial-N.trajectory.jsonl per trial under -trials)")
|
||||
forcePlanner := flag.Bool("force-planner", false, "suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate")
|
||||
anchorFlag := flag.String("anchor", anchorBlind, "suite mode: hypothesis arm — blind (no hypothesis) | correct | wrong; correct/wrong prefix each prompt with the task's authored seed and skip tasks that have none")
|
||||
outJSON := flag.String("json", "", "write the JSON report here (optional)")
|
||||
@@ -585,10 +585,11 @@ func runTask(cfg suiteConfig, t task) result {
|
||||
}
|
||||
|
||||
mtr.record(&r)
|
||||
if trajPath = lastSegmentTrajectory(runTrajDir, t.ID, r.Segments); trajPath != "" {
|
||||
stem := trajectoryStem(t.ID, cfg.trial, cfg.trials)
|
||||
if trajPath = lastSegmentTrajectory(runTrajDir, stem, r.Segments); trajPath != "" {
|
||||
recordTaskTrajectory(&r, trajPath, t)
|
||||
}
|
||||
if !auditAnswerRun(&r, runTrajDir, t) {
|
||||
if !auditAnswerRun(&r, runTrajDir, stem, t) {
|
||||
return r
|
||||
}
|
||||
// A killed child never writes metrics, so the deadline is the only place
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import "reasonix/internal/contract/tool"
|
||||
|
||||
// callNesting tells the model's own calls from calls made on its behalf: a
|
||||
// subagent's (stamped with ParentID) and those a running tool makes through
|
||||
// the agent's invoker (run_script's), which carry an id derived from their
|
||||
// caller's and arrive inside its dispatch→result window. Either kind ran
|
||||
// inside one top-level call, so it bought no model request of its own.
|
||||
type callNesting struct {
|
||||
open map[string]bool // top-level calls dispatched and not yet resulted
|
||||
}
|
||||
|
||||
// child reports whether a tool event belongs to a call made on the model's
|
||||
// behalf, and tracks which top-level calls are still running.
|
||||
func (n *callNesting) child(kind, id, parentID string) bool {
|
||||
if parentID != "" {
|
||||
return true
|
||||
}
|
||||
for caller := range n.open {
|
||||
if tool.IsNestedCallOf(id, caller) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case "tool_dispatch":
|
||||
if n.open == nil {
|
||||
n.open = map[string]bool{}
|
||||
}
|
||||
n.open[id] = true
|
||||
case "tool_result":
|
||||
delete(n.open, id)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -99,6 +99,7 @@ type trajScan struct {
|
||||
gapReason, gapCompl, gapPrompt int64
|
||||
|
||||
delegationToolMs map[string]int64
|
||||
nesting callNesting
|
||||
}
|
||||
|
||||
// modelAttempt is one sampling attempt's wall interval; planner marks attempts
|
||||
|
||||
@@ -25,6 +25,7 @@ func splitAtCorrect(path string, cutoffUnixMs int64) boundarySplit {
|
||||
}
|
||||
defer f.Close()
|
||||
inModel := true
|
||||
var nesting callNesting
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
|
||||
for sc.Scan() {
|
||||
@@ -32,7 +33,7 @@ func splitAtCorrect(path string, cutoffUnixMs int64) boundarySplit {
|
||||
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
if rec.Event == nil || rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
|
||||
if rec.Event == nil || rec.Event.Tool == nil || nesting.child(rec.Event.Kind, rec.Event.Tool.ID, rec.Event.Tool.ParentID) {
|
||||
continue
|
||||
}
|
||||
after := rec.TS > cutoffUnixMs
|
||||
@@ -80,6 +81,7 @@ func roundEnds(path string) []int64 {
|
||||
var ends []int64
|
||||
var lastResult int64
|
||||
inModel := true
|
||||
var nesting callNesting
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
|
||||
for sc.Scan() {
|
||||
@@ -87,7 +89,7 @@ func roundEnds(path string) []int64 {
|
||||
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
if rec.Event == nil || rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
|
||||
if rec.Event == nil || rec.Event.Tool == nil || nesting.child(rec.Event.Kind, rec.Event.Tool.ID, rec.Event.Tool.ParentID) {
|
||||
continue
|
||||
}
|
||||
switch rec.Event.Kind {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -36,7 +38,7 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
|
||||
var runErr error
|
||||
for _, seg := range segs {
|
||||
metricsPath := filepath.Join(metricsDir, fmt.Sprintf(".run-metrics-%d.json", seg.index))
|
||||
trajPath := segmentTrajectoryPath(trajDir, t.ID, seg, len(segs))
|
||||
trajPath := segmentTrajectoryPath(trajDir, trajectoryStem(t.ID, cfg.trial, cfg.trials), seg, len(segs))
|
||||
args := buildSegmentArgs(cfg, seg, metricsPath, trajPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, cfg.bin, args...)
|
||||
@@ -85,6 +87,35 @@ func legMetrics(path string) (m runMetrics, partial bool, err error) {
|
||||
return m, false, err
|
||||
}
|
||||
|
||||
// trajectoryStem names one run's trajectory files: the task id, and the trial
|
||||
// when -trials repeats the task, so a later trial cannot overwrite an earlier
|
||||
// one. A single run keeps the bare id every reader already knows.
|
||||
func trajectoryStem(id string, trial, trials int) string {
|
||||
if trials < 2 {
|
||||
return id
|
||||
}
|
||||
return fmt.Sprintf("%s.trial-%d", id, trial)
|
||||
}
|
||||
|
||||
// trajectoryTaskID is the task a trajectory file recorded: its name without
|
||||
// the trial mark of trajectoryStem or the leg mark of segmentTrajectoryPath.
|
||||
func trajectoryTaskID(path string) string {
|
||||
id := strings.TrimSuffix(filepath.Base(path), ".trajectory.jsonl")
|
||||
id = trimNumberedMark(id, ".seg")
|
||||
return trimNumberedMark(id, ".trial-")
|
||||
}
|
||||
|
||||
func trimNumberedMark(id, mark string) string {
|
||||
i := strings.LastIndex(id, mark)
|
||||
if i < 0 {
|
||||
return id
|
||||
}
|
||||
if _, err := strconv.Atoi(id[i+len(mark):]); err != nil {
|
||||
return id
|
||||
}
|
||||
return id[:i]
|
||||
}
|
||||
|
||||
// segmentTrajectoryPath keeps one file per leg so a resumed leg cannot truncate
|
||||
// the record of the one before it.
|
||||
func segmentTrajectoryPath(dir, id string, seg segment, total int) string {
|
||||
|
||||
@@ -153,8 +153,7 @@ func sftInputs(paths []string, suite, report string) (iter.Seq2[sftInput, error]
|
||||
}
|
||||
|
||||
func sftInputFor(path, suite string, tasks map[string]task, verdicts map[string]map[string]result) (*sftInput, error) {
|
||||
id := filepath.Base(path)
|
||||
id = id[:len(id)-len(".trajectory.jsonl")]
|
||||
id := trajectoryTaskID(path)
|
||||
records, digest, err := readSFTSnapshot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
## e2ebench A/B: `a.json` vs `b.json`
|
||||
|
||||
| Metric | A | B |
|
||||
|---|---:|---:|
|
||||
| Solved | 1/2 (50%) | 1/2 (50%) |
|
||||
| Pass@1 | 50% | 50% |
|
||||
| TTFT median | 0ms | 0ms |
|
||||
| TTCS median | 1m00s | 45.0s |
|
||||
| TTCS p90 | 1m00s | 45.0s |
|
||||
| Cache hit | n/a | n/a |
|
||||
| First-request cache hit | n/a | n/a |
|
||||
| Overthinking damage | n/a | n/a |
|
||||
| Model requests / solved | 16.0 | 14.0 |
|
||||
| Planner requests / solved | 0.0 | 0.0 |
|
||||
| Model rounds / solved | 0.0 | 0.0 |
|
||||
| Tool calls / solved | 24.0 | 24.0 |
|
||||
| Tokens / solved | 2400.0 | 2200.0 |
|
||||
| Wall seconds / solved | 150.0 | 135.0 |
|
||||
| Cost / solved | 0.1 | 0.0 |
|
||||
|
||||
**Marginal utility (A − B):** accuracy +0.0pp · wall/task +7.5s
|
||||
|
||||
| Class | A solved | B solved | Δ accuracy | A wall/task | B wall/task | Δ wall |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| bugfix | 1/1 | 1/1 | +0.0pp | 60.0s | 45.0s | +15.0s |
|
||||
| perf | 0/1 | 0/1 | +0.0pp | 90.0s | 90.0s | +0.0s |
|
||||
|
||||
|
||||
### Pareto: accuracy vs TTCS
|
||||
|
||||
```
|
||||
Accuracy
|
||||
100% |
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
| B ✗
|
||||
|
|
||||
40% |
|
||||
+----------------------------------------------→ TTCS
|
||||
43.5s 1m01s
|
||||
A=a B=b
|
||||
```
|
||||
|
||||
- ⚠️ `a` is **dominated** by `b`: at least as accurate and no slower — the unambiguous alarm
|
||||
- ✅ `b` is on the Pareto frontier (50% solved, TTCS median 45.0s)
|
||||
|
||||
|
||||
### Reproduction
|
||||
|
||||
- capability set: unknown (a report records no capability set)
|
||||
- task prompts: same 2 task ids and prompts
|
||||
- not compared: commit, build, configuration, grader, task files other than the prompt
|
||||
|
||||
| Rate | A | B | B − A (95% CI) | |
|
||||
|---|---:|---:|---:|---|
|
||||
| grader pass (solvable) | 1/2 | 1/2 | +0.0pp (-57.3, +57.3) | within noise |
|
||||
<sub>Per-solved figures divide each arm's accounted totals (failures included) by its accounted solves.</sub>
|
||||
@@ -0,0 +1,36 @@
|
||||
## e2ebench comparison: 2 arms
|
||||
|
||||
| Arm | Pass@1 | Solved | TTFT | TTCS median | TTCS p90 | Solved/hour | 1st-req cache | Requests/solved | Tokens/solved | Cost/solved |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `a` | 50% | 1/2 | 0ms | 1m00s | 1m00s | 24.0 | n/a | 16.0 | 2,400 | 0.0500 |
|
||||
| `b` | 50% | 1/2 | 0ms | 45.0s | 45.0s | 26.7 | n/a | 14.0 | 2,200 | 0.0400 |
|
||||
|
||||
### Pareto: accuracy vs TTCS
|
||||
|
||||
```
|
||||
Accuracy
|
||||
100% |
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
| B ✗
|
||||
|
|
||||
40% |
|
||||
+----------------------------------------------→ TTCS
|
||||
43.5s 1m01s
|
||||
A=a B=b
|
||||
```
|
||||
|
||||
- ⚠️ `a` is **dominated** by `b`: at least as accurate and no slower — the unambiguous alarm
|
||||
- ✅ `b` is on the Pareto frontier (50% solved, TTCS median 45.0s)
|
||||
|
||||
### Per-class winners
|
||||
|
||||
| Class | `a` | `b` | Winner |
|
||||
|---|---:|---:|---|
|
||||
| bugfix | 100% · 1m00s | 100% · 45.0s | b |
|
||||
| perf | 0% · — | 0% · — | a |
|
||||
|
||||
<sub>Per-solved figures divide each arm's accounted totals (failures included) by its accounted solves; TTCS charges a retried solve with its failed attempts' wall.</sub>
|
||||
@@ -20,9 +20,9 @@ type trajectorySummary struct {
|
||||
SpanMs int64 `json:"span_ms"`
|
||||
ToolMs int64 `json:"tool_ms"`
|
||||
ModelMs int64 `json:"model_ms"` // SpanMs − tool wall clock, floored at zero
|
||||
// Round decomposition: a round's gap runs from the turn start (or the
|
||||
// batch's last tool_result) to the next top-level tool_dispatch; the
|
||||
// final segment to the last record is the answer round.
|
||||
// ModelRounds counts executor round trips: one per batch of top-level calls
|
||||
// plus the answer segment. Calls a subagent or a running tool (run_script)
|
||||
// makes are not top-level and cut no round; retries in a gap add none.
|
||||
ModelRounds int `json:"model_rounds"`
|
||||
ModelGapTotalMs int64 `json:"model_gap_total_ms"`
|
||||
ModelGapP95Ms int64 `json:"model_gap_p95_ms"`
|
||||
@@ -105,6 +105,9 @@ type trajectorySummary struct {
|
||||
PromptTokensSeen int64 `json:"prompt_tokens_seen,omitempty"` // Σ prompt tokens (schema share denominator)
|
||||
PrefixResets int `json:"prefix_resets,omitempty"` // usage events with prefixChanged
|
||||
ConnectCalls int `json:"connect_calls,omitempty"` // connect_tool_source dispatches
|
||||
// NestedCalls counts results of calls a running tool made through the
|
||||
// agent's invoker; they are outside TopLevelCalls and every round figure.
|
||||
NestedCalls int `json:"nested_calls,omitempty"`
|
||||
|
||||
// Cold/warm evidence: the first top-level request's cache split. A cold
|
||||
// session pays the whole prefix as miss; a warmed one starts near-hit.
|
||||
@@ -421,9 +424,21 @@ func (t *trajScan) record(rec trajectoryRecord) {
|
||||
}
|
||||
t.compFrom = 0
|
||||
}
|
||||
if rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
|
||||
// Subagent calls overlap the parent's wall clock; counting them
|
||||
// would double-book the span and split the parent's rounds.
|
||||
t.recordTool(rec)
|
||||
}
|
||||
|
||||
// recordTool books a top-level tool event to its round and batch.
|
||||
func (t *trajScan) recordTool(rec trajectoryRecord) {
|
||||
tl := rec.Event.Tool
|
||||
if tl == nil {
|
||||
return
|
||||
}
|
||||
if t.nesting.child(rec.Event.Kind, tl.ID, tl.ParentID) {
|
||||
// Nested calls overlap their caller's wall clock; counting them would
|
||||
// double-book the span and split the caller's round.
|
||||
if tl.ParentID == "" && rec.Event.Kind == "tool_result" {
|
||||
t.s.NestedCalls++
|
||||
}
|
||||
return
|
||||
}
|
||||
switch rec.Event.Kind {
|
||||
@@ -434,9 +449,10 @@ func (t *trajScan) record(rec trajectoryRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
// closeGap ends one model round; recovery-tainted rounds are booked apart so
|
||||
// clean latency stays comparable across providers with different flake rates.
|
||||
// The gap is queued until its batch closes and can classify the round.
|
||||
// closeGap ends one model round: the gap from the turn start or the last
|
||||
// top-level tool_result to the next top-level tool_dispatch. Recovery-tainted
|
||||
// rounds are booked apart so clean latency stays comparable across providers
|
||||
// with different flake rates. The gap is queued until its batch classifies it.
|
||||
func (t *trajScan) closeGap(gap int64) {
|
||||
t.gaps = append(t.gaps, gap)
|
||||
t.pendingGaps = append(t.pendingGaps, gapInfo{
|
||||
|
||||
@@ -625,3 +625,62 @@ func TestSummarizeTrajectoryCountsRefusedRoundsAsWaste(t *testing.T) {
|
||||
t.Error("a refused round must carry its gap time into the waste breakdown")
|
||||
}
|
||||
}
|
||||
|
||||
// A run_script call's own calls run under ids derived from the script's, inside
|
||||
// its dispatch→result window. They are one model request, not one per call, so
|
||||
// they must neither cut model rounds nor reach round outcomes as top-level work.
|
||||
func TestSummarizeTrajectoryFoldsScriptCallsIntoTheirRound(t *testing.T) {
|
||||
path := filepath.Join(testenv.TempDir(t), "script.trajectory.jsonl")
|
||||
lines := []string{
|
||||
`{"ts":1000,"event":{"kind":"turn_started"}}`,
|
||||
`{"ts":1900,"event":{"kind":"usage","usage":{"promptTokens":10}}}`,
|
||||
`{"ts":2000,"event":{"kind":"tool_dispatch","tool":{"id":"call_s","name":"run_script"}}}`,
|
||||
`{"ts":2100,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.1","name":"read_file","readOnly":true}}}`,
|
||||
`{"ts":2200,"event":{"kind":"tool_result","tool":{"id":"call_s.1","name":"read_file","readOnly":true,"durationMs":100,"startedAt":2100,"endedAt":2200}}}`,
|
||||
`{"ts":2300,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.2","name":"read_file","readOnly":true}}}`,
|
||||
`{"ts":2400,"event":{"kind":"tool_result","tool":{"id":"call_s.2","name":"read_file","readOnly":true,"durationMs":100,"startedAt":2300,"endedAt":2400}}}`,
|
||||
`{"ts":2500,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.3","name":"edit_file"}}}`,
|
||||
`{"ts":2600,"event":{"kind":"tool_result","tool":{"id":"call_s.3","name":"edit_file","durationMs":100,"startedAt":2500,"endedAt":2600}}}`,
|
||||
`{"ts":2700,"event":{"kind":"tool_result","tool":{"id":"call_s","name":"run_script","durationMs":700,"startedAt":2000,"endedAt":2700}}}`,
|
||||
`{"ts":3900,"event":{"kind":"usage","usage":{"promptTokens":20}}}`,
|
||||
`{"ts":4000,"event":{"kind":"turn_done"}}`,
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
s, err := summarizeTrajectory(path)
|
||||
if err != nil {
|
||||
t.Fatalf("summarizeTrajectory: %v", err)
|
||||
}
|
||||
if s.ExecutorRequests != 2 {
|
||||
t.Fatalf("executor requests = %d, want 2", s.ExecutorRequests)
|
||||
}
|
||||
if s.ModelRounds != 2 {
|
||||
t.Errorf("model rounds = %d, want 2 (script round + answer), one per model request", s.ModelRounds)
|
||||
}
|
||||
if s.ModelGapTotalMs != 2300 {
|
||||
t.Errorf("model gap total = %d, want 2300 (1000 before the script, 1300 after it)", s.ModelGapTotalMs)
|
||||
}
|
||||
if s.TopLevelCalls != 1 || s.ToolBatches != 1 {
|
||||
t.Errorf("top-level calls/batches = %d/%d, want 1/1", s.TopLevelCalls, s.ToolBatches)
|
||||
}
|
||||
if s.NestedCalls != 3 {
|
||||
t.Errorf("nested calls = %d, want 3", s.NestedCalls)
|
||||
}
|
||||
if s.ToolMs != 700 {
|
||||
t.Errorf("tool ms = %d, want 700 (nested time is inside the script's)", s.ToolMs)
|
||||
}
|
||||
total := 0
|
||||
for _, n := range s.RoundOutcomes {
|
||||
total += n
|
||||
}
|
||||
if total != 2 || s.RoundOutcomes["finalization"] != 1 {
|
||||
t.Errorf("round outcomes = %v, want two rounds, one of them finalization", s.RoundOutcomes)
|
||||
}
|
||||
if ends := roundEnds(path); len(ends) != 1 || ends[0] != 2700 {
|
||||
t.Errorf("round ends = %v, want [2700]", ends)
|
||||
}
|
||||
if split := splitAtCorrect(path, 0); split.RoundsAfter != 1 || split.CallsAfter != 1 {
|
||||
t.Errorf("split = %+v, want one round and one call after the cutoff", split)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func trialResult(id string, trial int, passed bool) result {
|
||||
@@ -68,3 +73,83 @@ func TestTrialsLineIsSilentWithoutTrials(t *testing.T) {
|
||||
t.Fatalf("line = %q, want nothing for a run without trials", got)
|
||||
}
|
||||
}
|
||||
|
||||
// trajectoryAgent stands in for the reasonix binary and writes a one-record
|
||||
// trajectory wherever --trajectory points, so a test can see which files a run
|
||||
// leaves behind.
|
||||
func trajectoryAgent(t *testing.T) string {
|
||||
t.Helper()
|
||||
requireShellStub(t)
|
||||
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--trajectory) printf '{"ts":1,"event":{"kind":"turn_done"}}\n' > "$2"; shift 2;;
|
||||
--metrics) printf '{"steps":1,"complete":true}' > "$2"; shift 2;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
// Each trial records its own trajectory: a shared name would leave only the
|
||||
// last trial's file, and every digest of the earlier ones would read it.
|
||||
func TestTrialsKeepOneTrajectoryPerTrial(t *testing.T) {
|
||||
dir, taskDir := testenv.TempDir(t), testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "prompt.md"), []byte("p"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := suiteConfig{bin: trajectoryAgent(t), trials: 2, trajDir: dir}
|
||||
total := 0
|
||||
results := runTrials(cfg, task{ID: "demo", Prompt: "p", MaxSteps: 4, TimeoutSec: 60, dir: taskDir}, &total)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want 2", len(results))
|
||||
}
|
||||
got, err := filepath.Glob(filepath.Join(dir, "*.trajectory.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range got {
|
||||
got[i] = filepath.Base(got[i])
|
||||
}
|
||||
slices.Sort(got)
|
||||
want := []string{"demo.trial-1.trajectory.jsonl", "demo.trial-2.trajectory.jsonl"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("trajectory files = %v, want %v", got, want)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Trajectory == nil || filepath.Base(r.Trajectory.Path) != want[r.Trial-1] {
|
||||
t.Errorf("trial %d digest = %+v, want it read from %s", r.Trial, r.Trajectory, want[r.Trial-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A single trial keeps the name every reader already knows.
|
||||
func TestSingleTrialKeepsTheTaskTrajectoryName(t *testing.T) {
|
||||
if got := trajectoryStem("demo", 1, 1); got != "demo" {
|
||||
t.Fatalf("stem = %q, want demo", got)
|
||||
}
|
||||
if got := trajectoryStem("demo", 0, 0); got != "demo" {
|
||||
t.Fatalf("stem = %q, want demo", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Readers that need the task behind a file strip the trial and segment marks.
|
||||
func TestTrajectoryTaskIDReadsEveryFileShape(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"demo.trajectory.jsonl": "demo",
|
||||
"demo.trial-2.trajectory.jsonl": "demo",
|
||||
"demo.seg3.trajectory.jsonl": "demo",
|
||||
"demo.trial-2.seg3.trajectory.jsonl": "demo",
|
||||
"a.b.trial-10.trajectory.jsonl": "a.b",
|
||||
} {
|
||||
if got := trajectoryTaskID(filepath.Join("d", name)); got != want {
|
||||
t.Errorf("trajectoryTaskID(%s) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
// This window is the only client of its kernel, so a system notification
|
||||
// reaches the person who asked for it. Every pane gets the same wrapper,
|
||||
// not just the one the launch started with.
|
||||
notifications, notifySet := hostNotifications(cfg)
|
||||
notifications, notifySet, phoneLink := hostNotifications(cfg)
|
||||
// One fold behind the status icon: every pane's events on the way through,
|
||||
// so the tray surface answers from what the panes did rather than from a
|
||||
// count the shell kept for itself.
|
||||
@@ -474,7 +474,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
return nil, err
|
||||
}
|
||||
hub.StartRecoveryGC(ctx)
|
||||
startCloudRemote(ctx, cfg, logs, hub)
|
||||
startCloudRemote(ctx, cfg, logs, hub, phoneLink)
|
||||
phases.mark(startupBackground)
|
||||
return hub, nil
|
||||
}
|
||||
@@ -500,7 +500,7 @@ func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startu
|
||||
}
|
||||
}
|
||||
|
||||
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
|
||||
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService, phones *notify.PhoneLink) {
|
||||
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(logs, "remote cloud: %v\n", err)
|
||||
@@ -529,6 +529,12 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
}
|
||||
})
|
||||
}
|
||||
// Phone notifications leave through this connection, under this Studio's own
|
||||
// device credential.
|
||||
phones.Set(host)
|
||||
if registrar, ok := tasks.(interface{ SetCloudPhones(serve.CloudPhones) }); ok {
|
||||
registrar.SetCloudPhones(host)
|
||||
}
|
||||
go host.Run(ctx)
|
||||
}
|
||||
|
||||
@@ -573,13 +579,17 @@ func hostServeConfig(cfg config.ServeConfig) config.ServeConfig {
|
||||
// says today, because deciding here would make "notify me" mean "notify me
|
||||
// after a restart". The shared [notifications] config is the one setting this
|
||||
// window and the CLI both answer to.
|
||||
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings) {
|
||||
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings, *notify.PhoneLink) {
|
||||
set := notify.NewSettings(config.NotificationsConfig{})
|
||||
if cfg != nil {
|
||||
set.Store(cfg.Notifications)
|
||||
}
|
||||
sender := notify.NewPlatformSender()
|
||||
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set) }, set
|
||||
// One reporter for every runtime, so a burst across panes folds into one
|
||||
// report; its sender is the account connection, attached once that is up.
|
||||
link := ¬ify.PhoneLink{}
|
||||
phone := notify.NewPhone(link)
|
||||
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set).WithPhone(phone) }, set, link
|
||||
}
|
||||
|
||||
// studioTray answers for a shell that can put an icon back whenever the setting
|
||||
|
||||
@@ -480,7 +480,7 @@ func TestHostNotificationsCarryTheSharedSettingLive(t *testing.T) {
|
||||
{Notifications: config.NotificationsConfig{Enabled: true}},
|
||||
nil,
|
||||
} {
|
||||
wrap, set := hostNotifications(cfg)
|
||||
wrap, set, _ := hostNotifications(cfg)
|
||||
if got := wrap(plain); got == event.Sink(plain) {
|
||||
t.Fatalf("runtime was left unwrapped, so the switch could never reach it (cfg %+v)", cfg)
|
||||
}
|
||||
@@ -551,3 +551,45 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
|
||||
t.Fatalf("after the window started, the desktop posture is %q, want the shipped auto released", got)
|
||||
}
|
||||
}
|
||||
|
||||
type phoneReports chan [2]any
|
||||
|
||||
func (p phoneReports) NotifyPhones(_ context.Context, kind string, count int) error {
|
||||
p <- [2]any{kind, count}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A runtime's own events, through the wrapper every pane gets, reach the account
|
||||
// connection as a kind and a count and nothing else, and only once the switches
|
||||
// ask for it.
|
||||
func TestAnApprovalOnAPaneReachesThePhoneLinkAsATypeAndACount(t *testing.T) {
|
||||
cfg := &config.Config{Notifications: config.NotificationsConfig{Phone: config.PhoneNotificationsConfig{Enabled: true, Approval: true}}}
|
||||
wrap, set, link := hostNotifications(cfg)
|
||||
reports := make(phoneReports, 4)
|
||||
link.Set(reports)
|
||||
sink := wrap(serve.NewBroadcaster())
|
||||
|
||||
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "1", Tool: "bash", Subject: "git push --force"}})
|
||||
select {
|
||||
case got := <-reports:
|
||||
if got != [2]any{"approval", 1} {
|
||||
t.Fatalf("reported %v, want a type and a count and no text", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("an approval on a pane did not reach the phone link")
|
||||
}
|
||||
|
||||
sink.Emit(event.Event{Kind: event.AskRequest})
|
||||
select {
|
||||
case got := <-reports:
|
||||
t.Fatalf("reported %v for a kind whose switch is off", got)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
set.Store(config.NotificationsConfig{})
|
||||
sink.Emit(event.Event{Kind: event.ApprovalRequest})
|
||||
select {
|
||||
case got := <-reports:
|
||||
t.Fatalf("reported %v after the master switch was turned off", got)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
+267
-1
@@ -2,11 +2,102 @@
|
||||
status: active
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
reviewed: 2026-10-13
|
||||
reviewed: 2026-10-18
|
||||
---
|
||||
|
||||
# Foundation palette verification
|
||||
|
||||
## Current preset editor verification / 当前预设编辑页验收
|
||||
|
||||
final result: blocked
|
||||
|
||||
This section supersedes earlier preset-card visual acceptance below. The selected
|
||||
source is the user's attached `exec-e196c6a9-d8d9-437d-a6ee-0e99a3651845.png`,
|
||||
1487 × 1058 image pixels; CSS viewport and density are unspecified.
|
||||
|
||||
The image specifies a catalog followed by a separate content editor, not a drawer. The existing
|
||||
Settings shell remains the application-owned frame.
|
||||
|
||||
The latest editor instruction supersedes the original advanced disclosure:
|
||||
the user's `codex-clipboard-c3951359-4571-4572-ad30-9f4517a2f7db.png` reference
|
||||
places both textareas and the runtime-context switch directly in the editor.
|
||||
Scope and ID are also exposed. Settings navigation now reads Persona / 人设.
|
||||
|
||||
本节取代下方旧预设卡片的视觉验收。采用用户重新指定的首张效果图,列表与独立
|
||||
编辑页切换,保留现有设置外框。编辑态为新建、名称与用途已填写、能力及高级设置
|
||||
收起、个人范围、取消与保存操作固定在底部。
|
||||
|
||||
- Implementation screenshot: unavailable. Browser acquisition of the local
|
||||
preview was rejected by the browser URL security policy. No alternative browser
|
||||
surface or raw browser command was used to bypass the refusal.
|
||||
- Viewport, implementation pixel dimensions, CSS size and density normalization:
|
||||
unmeasured. Full-view and focused visual comparisons remain blocked.
|
||||
- Typography: existing Studio family and weight tokens retained; rendering,
|
||||
wrapping and optical weight require browser comparison.
|
||||
- Spacing/layout: separate editor, expandable rows and flex footer implemented;
|
||||
actual proportions, scrolling and narrow-screen overflow remain unverified.
|
||||
- Colors: existing surface, text, focus and border tokens reused; rendered theme
|
||||
contrast remains unverified.
|
||||
- Assets: existing product icons retained; no generated product assets or new
|
||||
handcrafted icon geometry. Source brand is supplied by the existing shell.
|
||||
- Copy: name, purpose, persona, capabilities, scope and ID follow the selected
|
||||
references, with Chinese/English labels. Existing backend limits remain
|
||||
authoritative rather than adopting illustrative mockup character limits.
|
||||
- Interaction regressions cover creation, copy, literal persona fields, invalid
|
||||
parameters, failed saves, navigation/close confirmation, pending saves and focus.
|
||||
These are jsdom checks and do not substitute for browser visual evidence.
|
||||
|
||||
The catalog-button follow-up uses the user's 1374 × 916 browser annotation as
|
||||
the before evidence. The create button now uses one horizontal flex row, a
|
||||
36px minimum height and 16px horizontal padding.
|
||||
|
||||
The heading uses a two-column
|
||||
grid, with its description on a separate full-width row. After-render visual
|
||||
verification remains blocked by the browser policy described above.
|
||||
|
||||
Remaining checklist:
|
||||
|
||||
1. Acquire the authorized preview and capture the specified creation state and catalog.
|
||||
2. Compare the full view and editor regions; verify light/dark, narrow scrolling and console output.
|
||||
3. Resolve P0–P2 findings before marking visual acceptance passed.
|
||||
|
||||
Earlier passing sections below are historical evidence for their own changes.
|
||||
|
||||
待办:恢复浏览器访问后,在相同编辑状态比较整页与局部,并检查深浅主题、窄屏
|
||||
滚动及控制台;解决 P0–P2 问题后再将视觉验收标记为通过。
|
||||
|
||||
## Built-in cards and new-task default / 内置卡片与新任务默认
|
||||
|
||||
final result: blocked
|
||||
|
||||
Source: the user's selected Harness screenshot
|
||||
`codex-clipboard-be3c8df5-ada5-4683-a66d-81cdf31010e7.png`, 1178 × 760 image pixels.
|
||||
It is a section crop; viewport, CSS size and density are unspecified.
|
||||
|
||||
Implementation capture and full-view/focused comparison remain unavailable
|
||||
because browser observation was rejected by the URL security policy. No
|
||||
alternative browser surface was used to bypass that refusal.
|
||||
|
||||
- Typography: inherited Studio font, scale and weight tokens. Rendering remains
|
||||
unverified; source-specific font substitution was not introduced.
|
||||
- Layout: responsive two-column cards, large rounded borders, equal-height bodies,
|
||||
separate footer actions and single-column narrow layouts. Rendered rhythm and
|
||||
scrolling remain unverified.
|
||||
- Color: existing surface, border, text and selected tokens. The selected badge
|
||||
uses the text/page pair. Theme contrast still needs browser inspection.
|
||||
- Assets: supplied product icon library reused for copy; no new raster assets
|
||||
or handcrafted icon geometry.
|
||||
- Content: Standard/Minimal remain the shipped modes; IDs remain canonical.
|
||||
Details, usage and copy remain separate from the card's default selection.
|
||||
- Behavior: card clicks persist a machine-local built-in choice. New local and
|
||||
remote requests carry it explicitly; history and explicit per-task choices
|
||||
retain priority. Old server preferences alone remain inert.
|
||||
|
||||
待浏览器恢复后,对照所选卡片截图检查字体、留白、颜色、图标和文案,并覆盖
|
||||
深浅主题、窄屏、默认状态及展开的使用说明。当前功能测试不能替代视觉验收。
|
||||
|
||||
## Historical palette verification / 历史配色验收
|
||||
|
||||
final result: passed
|
||||
|
||||
Clear Blue and Tea Green implement the two approved colour directions for
|
||||
@@ -85,6 +176,163 @@ did not specify a dark layout. Phone captures confirm no horizontal overflow.
|
||||
Windows native installer acceptance and hosted CI are separate from these
|
||||
local results. The production build retains its existing large-chunk warning.
|
||||
|
||||
# Composer composition placement / 输入框组合布局
|
||||
|
||||
final result: passed
|
||||
|
||||
The annotated request moves the existing composition selector and management
|
||||
entry beside the run status. It preserves the existing screen, visual tokens
|
||||
and session behavior. / 按标注将组合选择与管理入口移到运行状态旁,沿用现有界面。
|
||||
|
||||
## Evidence / 证据
|
||||
|
||||
- Source visual truth: author-approved composer annotation (1310 × 1826 pixels; browser chrome included, density unspecified). The user attachment remains private; the synthetic baseline below records the same controls.
|
||||
- Matched source capture: `docs/images/agent-compositions/composer-before-light-wide.png`.
|
||||
- Implementation: `docs/images/agent-compositions/composer-light-wide.png`.
|
||||
- Full-view comparison: the matched before/after captures above were opened together, each 1440 × 1000 pixels, 1440 × 1000 CSS px, device scale factor 1. No density resampling.
|
||||
- Focused comparison: composer controls from those captures were compared together at original pixel density; `composer-context-light.png` records the final control arrangement.
|
||||
- Additional captures: `composer-light-390.png`, `composer-dark-390.png` (390 × 844 CSS/pixel dimensions), and `composer-dark-wide.png` (1440 × 1000), all in the same image directory.
|
||||
- State: local Mock preview, Chinese, idle, General selected, existing input draft and missing-key notice. No provider request submitted.
|
||||
|
||||
## Findings / 结论
|
||||
|
||||
No actionable P0/P1/P2 differences remain. / 无需修复的 P0/P1/P2 差异。
|
||||
|
||||
- Typography: existing UI family, 11 px medium-weight controls and status typography retained; long names remain bounded by the selector.
|
||||
- Layout: selector begins 16 px after run status; controls occupy the previously unused top row. The bottom toolbar fits one row at 1440 px. At 390 px the selector, status and management entry remain visible, with document width exactly 390 px.
|
||||
- Colors: existing semantic theme tokens and hover/focus styles reused in both light and dark mode; no new palette.
|
||||
- Assets: logo and icons preserved; no generated or replacement imagery.
|
||||
- Content: composition names, source labels, error notice and management action retained. The placement is the only intentional visual change.
|
||||
- Accessibility: composition controls sit outside `role="status"` and its live announcements. Accessible names and existing lock behavior are retained.
|
||||
|
||||
## Checks / 验证
|
||||
|
||||
- Switched General → Code review → General through the relocated selector.
|
||||
- Opened Agent presets through the relocated management entry, then closed it.
|
||||
- Confirmed the selector is absent from the bottom toolbar in the real DOM.
|
||||
- Browser warning/error log after interaction: empty.
|
||||
- Typecheck and `make check`: passed.
|
||||
- Focused interaction tests: 92 passed; full frontend suite: 632 files, 5,220 tests passed.
|
||||
- Locked-session/new-session flow covered by the existing picker interaction test.
|
||||
|
||||
## Comparison history / 对照记录
|
||||
|
||||
First rendered comparison passed; no P0/P1/P2 visual repair iteration was needed.
|
||||
The matched before/after captures demonstrate the requested move and reduced
|
||||
bottom-toolbar wrapping. / 首轮对照通过,上移后底栏在宽屏减少一行。
|
||||
|
||||
## Limits and next check / 范围
|
||||
|
||||
Browser-owned layout only; native titlebar and shell behavior are unchanged and
|
||||
were not re-tested. No provider execution or new CI result is claimed for this
|
||||
layout edit. / 本次验证浏览器布局,不涉及原生窗口或模型执行。
|
||||
|
||||
Checklist complete: placement, selection, management, live-region separation,
|
||||
light/dark, wide/narrow, typecheck, affected and full frontend tests.
|
||||
|
||||
# Unified composition menu / 统一组合菜单
|
||||
|
||||
final result: passed
|
||||
|
||||
This section supersedes the separate selector/management controls above.
|
||||
The annotation asks to combine those entries without changing the surrounding
|
||||
composer. / 本节替代上述独立选择器与管理按钮,按标注合并为一个入口。
|
||||
|
||||
## Source and capture / 来源与截图
|
||||
|
||||
- Source visual truth: author-approved annotation showing the composition selector and adjacent management button, 452 × 312 pixels; density and full viewport unspecified. The user attachment remains private.
|
||||
- Current-run baseline: `docs/images/agent-compositions/composition-menu-before.png`, 1440 × 1000 CSS/pixel dimensions, scale factor 1.
|
||||
- Implementation: `composition-menu-light-wide.png`, `composition-menu-dark-wide.png` (1440 × 1000) and `composition-menu-light-390.png`, `composition-menu-dark-390.png` (390 × 844), all in the same directory, scale factor 1.
|
||||
- Focused implementation: `composition-menu-detail.png`, 344 × 364 pixels at scale factor 1, including menu and trigger.
|
||||
- Full-view comparison: `composition-menu-before.png` and `composition-menu-light-wide.png` were opened together. Baseline is closed; implementation is open to assess the added menu and unchanged surrounding composer.
|
||||
- Focused comparison: the annotation and `composition-menu-detail.png` were opened together without density resampling. Source typography cannot support a 1:1 pixel comparison; the matched viewport supplies the product typography/layout reference.
|
||||
- State: local Mock preview, Chinese, idle, Code review selected. No provider execution.
|
||||
|
||||
## Flow audit / 流程检查
|
||||
|
||||
1. Read the active composition: healthy after combining the entries. A single trigger shows name and localized source. The separate ellipsis had no visible explanation; its action is now explicit.
|
||||
2. Choose a composition: healthy. Menu rows show capability notes and source, mark the current choice, and close after selection. Evidence: `composition-menu-light-wide.png` and `composition-menu-selected.png`.
|
||||
3. Manage presets: healthy. A separated footer opens the existing management page. Evidence: `composition-menu-manage.png`. Management costs one extra click; frequent switching retains the same click count.
|
||||
|
||||
## Fidelity and interaction / 视觉与交互
|
||||
|
||||
- Typography: existing UI family and weights; 13 px choice titles and 11 px notes. Source labels sit beside names rather than consuming another row.
|
||||
- Spacing: existing composer-menu recipe, 320 px menu width, 12 px radius and 8 px inner padding. At 390 px, the menu spans x=64…384 and the document remains exactly 390 px wide.
|
||||
- Colors: existing surface, overlay, text, muted and focus tokens work in both themes; no new palette.
|
||||
- Assets: existing chevron and brand assets retained; no new image assets.
|
||||
- Copy: localized Built in/Personal/Project labels replace raw source identifiers. Management is a named footer action.
|
||||
- Accessibility: trigger name includes the active composition and lock state; selected rows expose current state. Keyboard arrows skip disabled rows; Enter selects, Escape closes and restores trigger focus. This is scoped interaction evidence, not a full accessibility compliance claim.
|
||||
- Safety of interaction: started/busy conversations never rebind through this menu. The same menu offers explicitly labeled new-conversation actions and management; hosts without creation support still expose management.
|
||||
|
||||
## Iteration and validation / 迭代与验证
|
||||
|
||||
- First inspection found source metadata on an extra line, making each choice unnecessarily tall. The item title now uses a flex row; revised captures show compact name/source pairs. No actionable P0/P1/P2 visual issues remain.
|
||||
- A later resize check found a P2 shared positioning defect: after 390 → 1440 px, menu left remained 88 px while its trigger settled at 570 px. Window resize preceded fold publication and sidebar transition completion.
|
||||
- Shared floating placement now responds to fold publication and transition completion. The revised dark-wide capture and browser rectangles show both left edges at 570 px.
|
||||
- A deterministic real-Picker regression fails without the positioning fix and also checks cleanup after closing.
|
||||
- Verified selection, management, Escape and responsive placement in the browser; warning/error log was empty.
|
||||
- Regression tests cover keyboard focus, distinct action identities, failed/pending selections, locked sessions, management without creation support, equal names from different sources, and deleted bound presets.
|
||||
- Final validation after the resize repair: typecheck passed; frontend full suite passed, 633 files and 5,225 tests. Repository checks passed without increasing a budget.
|
||||
- Native shell behavior and provider execution are outside this layout change; hosted CI and publication status are reported separately in the pull request.
|
||||
|
||||
Checklist complete: one entry, grouped choices/actions, clear current state,
|
||||
retained locks, keyboard access, themes, narrow viewport and regression checks.
|
||||
|
||||
## Studio integration verification / Studio 合并验证
|
||||
|
||||
The upstream integration preserves the top-row Agent composition trigger and
|
||||
the unified selection/management menu. The upstream screen-reader-only run
|
||||
label remains inside the live status region; interactive controls remain
|
||||
outside it. Composition menu text follows the shared text-size setting.
|
||||
|
||||
合并后保留顶部 Agent 组合入口与统一的选择、管理菜单,同时沿用上游运行状态的
|
||||
读屏处理。浏览器复核覆盖组合切换、管理入口,以及菜单展开时从宽屏缩至窄屏
|
||||
的定位与换行。完整前端测试通过:651 个文件、5,342 个用例。
|
||||
|
||||
| Browser | Light | Dark |
|
||||
| --- | --- | --- |
|
||||
| 1440 × 1000 | Capture (local capture) | Capture (local capture) |
|
||||
| 390 × 844 | Capture (local capture) | Capture (local capture) |
|
||||
|
||||
These captures use synthetic Mock data. Native Electron shell behavior was
|
||||
not requalified by this browser integration check.
|
||||
|
||||
## Standard and Minimal modes / 标准与精简模式
|
||||
|
||||
The management page references the supplied Harness card hierarchy while using
|
||||
Studio typography and surface tokens. Only Standard and Minimal are shipped;
|
||||
custom definitions remain separate. Standard retains its existing stable ID.
|
||||
Minimal uses native terminal/image tools and compaction without Cordis plugins.
|
||||
|
||||
管理页保留内置卡片、默认标记、模式说明与独立自定义区域。宽屏并排,390px
|
||||
窄屏纵向排列且可滚动。默认选择与当前会话绑定分离;复制后继续使用原编辑器。
|
||||
入口只展示模式名和箭头,位于左侧;运行状态与 token 位于右侧,来源与锁定
|
||||
信息保留在菜单、提示和无障碍名称中。
|
||||
|
||||
| Browser | Light | Dark |
|
||||
| --- | --- | --- |
|
||||
| 1440 × 1000 | Cards (local capture) | Cards (local capture) |
|
||||
| 390 × 844 | Cards (local capture) | Cards (local capture) |
|
||||
|
||||
Composer idle (local capture) and
|
||||
running (local capture) captures
|
||||
show the compact mode trigger with separate run information. These are browser
|
||||
Mock captures; the source annotation image is not published.
|
||||
|
||||
The full frontend suite passes 651 files and 5,343 tests. Mechanism checks retain
|
||||
explicit custom read-only fixtures for proxy, late-registration and child-agent
|
||||
ceilings. Minimal's terminal is intentionally allowed. Text-only models report
|
||||
an unavailable image dependency instead of silently changing the tool set.
|
||||
|
||||
After the subsequent MCP-follow integration, the full frontend suite passes
|
||||
651 files and 5,347 tests. Provider-boundary and race regressions confirm that
|
||||
automatic server-tool approval cannot replace an existing composition's pinned
|
||||
contract; a new conversation can bind the updated catalog.
|
||||
|
||||
The native-video integration passes 651 frontend files and 5,348 tests. Combined
|
||||
projection regressions retain bindings/video references in stored history while
|
||||
removing local binding metadata and answered video payloads from model requests.
|
||||
|
||||
## Workspace preparation / 工作目录准备
|
||||
|
||||
The workspace palette and preparation states reuse existing typography and
|
||||
@@ -96,3 +344,21 @@ QA passed. Captures remain in
|
||||
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
|
||||
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
|
||||
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
|
||||
|
||||
|
||||
## Maintainer opt-in revision / 默认关闭调整
|
||||
|
||||
Standard and Minimal cards, the left-side name-only picker and right-side run
|
||||
information are preserved. Preferences are labeled as preferences; they do not
|
||||
activate a mode. No selection and Standard remain unbound. Complete replacement
|
||||
is removed from the editor; selected personas supplement first-party guidance.
|
||||
|
||||
保留标准/精简卡片、左侧仅模式名的入口及右侧运行信息。首选项不自动启用;
|
||||
未选择及标准模式不绑定。编辑器移除完整替换开关,人设仅补充内置指令。
|
||||
|
||||
Per maintainer request, composition screenshots are local evidence and are not
|
||||
committed. Historical capture references above describe earlier verification.
|
||||
Current browser checks and hosted workflow results are reported separately.
|
||||
|
||||
按维护者要求,组合截图仅保留为本地证据,不提交到仓库。上方记录为历史验证;
|
||||
当前浏览器检查与远端工作流结论分开报告。
|
||||
@@ -135,14 +135,16 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
|
||||
}
|
||||
}
|
||||
|
||||
// installable reports whether an artifact installs itself rather than expecting
|
||||
// the reader to place it: a disk image to drag from, an installer to run, a
|
||||
// package for dpkg.
|
||||
// installable reports whether an artifact is a download a person is offered: a
|
||||
// disk image to drag from, an installer to run, a package for dpkg or rpm, or an
|
||||
// AppImage to run in place. The AppImage is a download only; no native_packages
|
||||
// key names it, so no client replaces one.
|
||||
func installable(name string) bool {
|
||||
return strings.HasSuffix(name, ".dmg") ||
|
||||
strings.HasSuffix(name, "-installer.exe") ||
|
||||
strings.HasSuffix(name, ".deb") ||
|
||||
strings.HasSuffix(name, ".rpm")
|
||||
strings.HasSuffix(name, ".rpm") ||
|
||||
strings.HasSuffix(name, ".AppImage")
|
||||
}
|
||||
|
||||
// macArchiveKeys maps a macOS archive to the platforms it can install. The
|
||||
|
||||
@@ -340,3 +340,36 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An AppImage is a download a person is offered and nothing an updater
|
||||
// replaces: it must not appear as a platform asset or a native package, and
|
||||
// adding it must not move any entry a shipped client reads.
|
||||
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
|
||||
a, ok := m.Downloads[name]
|
||||
if !ok || a.Sig != a.URL+".minisig" {
|
||||
t.Errorf("downloads[%s] = %+v, %v; want a signed download", name, a, ok)
|
||||
}
|
||||
}
|
||||
if len(m.NativePackages) != 1 || !strings.HasSuffix(m.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
|
||||
t.Errorf("native packages = %+v, want only the .deb", m.NativePackages)
|
||||
}
|
||||
if len(m.Platforms) != 0 {
|
||||
t.Errorf("platforms = %+v: an AppImage must not resolve as an update", m.Platforms)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 423 B |
Binary file not shown.
|
After Width: | Height: | Size: 927 B |
@@ -99,11 +99,19 @@ linux:
|
||||
target:
|
||||
- deb
|
||||
- rpm
|
||||
# Download only: it does not update itself, and nothing here installs it.
|
||||
- AppImage
|
||||
icon: assets/icon.png
|
||||
category: Development
|
||||
maintainer: esengine <[email protected]>
|
||||
artifactName: ReasonixStudio-linux-${arch}.${ext}
|
||||
|
||||
appImage:
|
||||
# Stated rather than inherited: the release vocabulary is the same for every
|
||||
# Linux file, and canonical-artifact-names.js turns whatever architecture
|
||||
# spelling electron-builder puts here (x64, x86_64) into amd64 or fails.
|
||||
artifactName: ReasonixStudio-linux-${arch}.${ext}
|
||||
|
||||
deb:
|
||||
# Named, not defaulted. electron-builder takes the package name from
|
||||
# package.json's `name` unless told otherwise, which would publish
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"build:dist": "electron-builder",
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"browser-resize-live": "electron test/browser_resize_live.js",
|
||||
"browser-fit-live": "electron test/browser_fit_live.js",
|
||||
"browser-fit-pages": "electron test/browser_fit_pages.js",
|
||||
"image-live": "electron test/image_live.js",
|
||||
"image-pane-live": "electron test/image_pane_live.js",
|
||||
"sidepanel-live": "electron test/sidepanel_live.js",
|
||||
@@ -25,9 +28,9 @@
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.7.0",
|
||||
"electron": "44.7.2",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright": "1.64.0"
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
@@ -13,6 +13,10 @@ 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"';
|
||||
// macOS tints a template image itself, so the menu-bar glyph is the mark alone,
|
||||
// black on transparent, framed square around it with a one-pixel margin at 18px.
|
||||
const TEMPLATE_VIEWBOX = 'viewBox="277 277 470 470"';
|
||||
const TEMPLATE_SCALES = [[18, ""], [36, "@2x"]];
|
||||
// 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.
|
||||
@@ -79,6 +83,13 @@ 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 markSvg = svg
|
||||
.replace(/<rect [^>]*\/>\s*/g, "")
|
||||
.replace('fill="url(#gold)"', 'fill="#000"')
|
||||
.replace('viewBox="0 0 1024 1024"', TEMPLATE_VIEWBOX);
|
||||
for (const [size, suffix] of TEMPLATE_SCALES) {
|
||||
fs.writeFileSync(path.join(ASSETS, `trayTemplate${suffix}.png`), await render(page, markSvg, size));
|
||||
}
|
||||
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 render(page, winSvg, size)]);
|
||||
|
||||
Generated
+14
-14
@@ -9,14 +9,14 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.7.0
|
||||
version: 44.7.0([email protected])
|
||||
specifier: 44.7.2
|
||||
version: 44.7.2([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected])([email protected])
|
||||
playwright:
|
||||
specifier: 1.64.0
|
||||
version: 1.64.0
|
||||
specifier: 1.64.1
|
||||
version: 1.64.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-u3Pbz0Hi1sSSwBMz2v8LiagsfUG4QURPUe/qphM+g4THKP8o4BYdnuboyhlLO8HIC/KfV5rvltyacbP4jEq9yw==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -762,13 +762,13 @@ packages:
|
||||
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected].0([email protected]):
|
||||
[email protected].2([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
@@ -1942,11 +1942,11 @@ snapshots:
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected].0:
|
||||
[email protected].1:
|
||||
dependencies:
|
||||
playwright-core: 1.64.0
|
||||
playwright-core: 1.64.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -116,7 +116,7 @@ class BrowserViews {
|
||||
}
|
||||
onEvent(method, params);
|
||||
});
|
||||
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false };
|
||||
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false, panel: null, override: null, scale: 1 };
|
||||
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
|
||||
this.watchLoads(targetId, entry);
|
||||
let closed = false;
|
||||
@@ -140,6 +140,15 @@ class BrowserViews {
|
||||
return {
|
||||
targetId,
|
||||
send: (method, params) => {
|
||||
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
|
||||
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
|
||||
try {
|
||||
params = scaleInput(method, params, entry.scale);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
// A capture puts the page's emulation back without its scale.
|
||||
if (method === "Page.captureScreenshot") return contents.debugger.sendCommand(method, params).finally(() => void this.fit(entry, true));
|
||||
if (!method.startsWith("Input.")) return contents.debugger.sendCommand(method, params);
|
||||
entry.synthUntil = Date.now() + 250;
|
||||
return this.wake.run(() => contents.debugger.sendCommand(method, params));
|
||||
@@ -164,7 +173,7 @@ class BrowserViews {
|
||||
// which during a turn is constantly, and nearly always the same rectangle:
|
||||
// re-adding the view and setting the bounds it already has is work for
|
||||
// every one of those.
|
||||
if (this.shown === targetId && sameRect(entry.at, at)) return;
|
||||
if (this.shown === targetId && sameRect(entry.panel, at)) return;
|
||||
this.placed++;
|
||||
for (const [id, other] of this.entries) {
|
||||
if (id !== targetId) this.putAway(other.view, other);
|
||||
@@ -176,9 +185,49 @@ class BrowserViews {
|
||||
if (entry.attached && !entry.at) this.win.contentView.removeChildView(entry.view);
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
entry.attached = true;
|
||||
entry.panel = at;
|
||||
this.fit(entry);
|
||||
this.keepKeyboard();
|
||||
}
|
||||
|
||||
// fit puts the view where its page is drawn whole and tells the page how it is
|
||||
// scaled; a page parked out of sight is not scaled. force sends the scale again
|
||||
// though it has not changed, for a capture that put the page's emulation back.
|
||||
fit(entry, force = false) {
|
||||
if (!entry.panel) return Promise.resolve();
|
||||
const { at, scale } = placement(entry.panel, entry.override && { width: entry.override.width, height: entry.override.height });
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
this.keepKeyboard();
|
||||
return this.scaled(entry, scale, force);
|
||||
}
|
||||
|
||||
scaled(entry, scale, force = false) {
|
||||
if (!entry.override || (entry.scale === scale && !force)) return Promise.resolve();
|
||||
entry.scale = scale;
|
||||
return this.override(entry).catch(() => {});
|
||||
}
|
||||
|
||||
override(entry) {
|
||||
return entry.view.webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { ...entry.override, scale: entry.scale });
|
||||
}
|
||||
|
||||
// emulate records the viewport a page was told to lay out in, which the view
|
||||
// is then fitted to; null is the page going back to the size of its view.
|
||||
async emulate(entry, params) {
|
||||
entry.override = sized(params) ? { ...params } : null;
|
||||
if (!entry.override) {
|
||||
entry.scale = 1;
|
||||
if (entry.panel) this.fit(entry);
|
||||
return entry.view.webContents.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {});
|
||||
}
|
||||
entry.scale = 1;
|
||||
if (entry.panel) {
|
||||
const { at, scale } = placement(entry.panel, entry.override);
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
entry.scale = scale;
|
||||
}
|
||||
return this.override(entry);
|
||||
}
|
||||
|
||||
// Attaching a view can leave nobody holding the keyboard, and the platform
|
||||
@@ -253,7 +302,11 @@ class BrowserViews {
|
||||
putAway(view, entry) {
|
||||
const { height } = this.win.getContentBounds();
|
||||
view.setBounds({ x: 1 - AGENT_VIEWPORT.width, y: height - 1, ...AGENT_VIEWPORT });
|
||||
if (entry) entry.at = null;
|
||||
if (entry) {
|
||||
entry.at = null;
|
||||
entry.panel = null;
|
||||
void this.scaled(entry, 1);
|
||||
}
|
||||
}
|
||||
|
||||
control(targetId, action) {
|
||||
@@ -420,6 +473,44 @@ async function loadResult(contents, url) {
|
||||
}
|
||||
}
|
||||
|
||||
// Where a page's view sits in the rectangle the panel gave it. A page laid out
|
||||
// in a viewport of its own is shown whole: scaled down to fit and never up,
|
||||
// centred, with the view sized to exactly what it draws.
|
||||
const sized = (v) => !!v && Number.isFinite(v.width) && Number.isFinite(v.height) && v.width > 0 && v.height > 0;
|
||||
|
||||
function placement(panel, emulated) {
|
||||
if (!sized(emulated)) return { at: panel, scale: 1 };
|
||||
const scale = Math.min(1, panel.width / emulated.width, panel.height / emulated.height);
|
||||
const width = Math.max(1, Math.round(emulated.width * scale));
|
||||
const height = Math.max(1, Math.round(emulated.height * scale));
|
||||
const x = panel.x + Math.round((panel.width - width) / 2);
|
||||
const y = panel.y + Math.round((panel.height - height) / 2);
|
||||
return { at: { x, y, width, height }, scale };
|
||||
}
|
||||
|
||||
// Input that carries a point. The page reads it in the viewport it was told it
|
||||
// has, the view draws that viewport scaled, and a point meant for the page lands
|
||||
// where it is drawn only after the same scaling. The mouse is measured to; the
|
||||
// rest are not, and a point that silently lands elsewhere is worse than a
|
||||
// refusal, so they are refused while the page is scaled.
|
||||
const POINTED_INPUT = new Set([
|
||||
"Input.dispatchTouchEvent",
|
||||
"Input.dispatchDragEvent",
|
||||
"Input.emulateTouchFromMouseEvent",
|
||||
"Input.synthesizeTapGesture",
|
||||
"Input.synthesizeScrollGesture",
|
||||
"Input.synthesizePinchGesture",
|
||||
]);
|
||||
|
||||
function scaleInput(method, params, scale) {
|
||||
if (scale === 1) return params;
|
||||
if (method === "Input.dispatchMouseEvent") return { ...params, x: params.x * scale, y: params.y * scale };
|
||||
if (POINTED_INPUT.has(method)) {
|
||||
throw Object.assign(new Error(`${method} is not available while the page is scaled to fit the panel`), { code: "input_unscaled" });
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function sameRect(a, b) {
|
||||
return !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
}
|
||||
@@ -428,4 +519,4 @@ function validRect(rect) {
|
||||
return rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS };
|
||||
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS, placement, scaleInput };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
// The kernel's gate answers every request without the launch credential with
|
||||
// 403, and a cookie belongs to the host, not the port, so anything that removes it (an
|
||||
// eviction, a page clearing cookies for 127.0.0.1) leaves the window refused
|
||||
// until a relaunch. The shell holds the token, so it puts the cookie back.
|
||||
function keepCredential(ses, cookie, log) {
|
||||
const host = new URL(cookie.url).hostname;
|
||||
const restore = (cause) => {
|
||||
log(`credential cookie removed (${cause}); restoring`);
|
||||
ses.cookies.set(cookie).catch((err) => log(`credential cookie restore failed: ${err.message}`));
|
||||
};
|
||||
const onChanged = (_event, changed, cause, removed) => {
|
||||
if (!removed || changed.name !== cookie.name || cause === "overwrite") return;
|
||||
if (changed.domain.replace(/^\./, "") !== host) return;
|
||||
restore(cause);
|
||||
};
|
||||
ses.cookies.on("changed", onChanged);
|
||||
return () => ses.cookies.removeListener("changed", onChanged);
|
||||
}
|
||||
|
||||
module.exports = { keepCredential };
|
||||
@@ -32,10 +32,12 @@ const { groundFor, themeSourceFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { stateFile, openWindow } = require("./windowstate");
|
||||
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
|
||||
const { processMemoryHandler } = require("./procmem");
|
||||
const { captureHandler } = require("./capture");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
const { raise } = require("./raise");
|
||||
const { keepCredential } = require("./credential");
|
||||
const { actHandler } = require("./acts");
|
||||
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
|
||||
|
||||
@@ -280,7 +282,7 @@ function cleanUpLegacyInstalls() {
|
||||
// it, Strict because no cross-site navigation ever needs to carry it, and no
|
||||
// expiry so it dies with this session rather than outliving the launch.
|
||||
async function armCredential(ready) {
|
||||
await session.defaultSession.cookies.set({
|
||||
const cookie = {
|
||||
url: ready.origin + "/",
|
||||
name: TOKEN_COOKIE,
|
||||
value: ready.token,
|
||||
@@ -288,7 +290,9 @@ async function armCredential(ready) {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
});
|
||||
};
|
||||
await session.defaultSession.cookies.set(cookie);
|
||||
keepCredential(session.defaultSession, cookie, (line) => logs.shell.line(line));
|
||||
}
|
||||
|
||||
// The close button hides where an icon can bring the window back, and quits
|
||||
@@ -392,6 +396,7 @@ nativeTheme.on("updated", () => {
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
|
||||
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
|
||||
|
||||
ipcMain.handle("window:unread-badge", (event, unread) => {
|
||||
const target = fromWindow(event);
|
||||
|
||||
@@ -30,6 +30,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
captureWindow: () => ipcRenderer.invoke("window:capture"),
|
||||
// What the shell's graphics are running as this launch.
|
||||
graphics: () => ipcRenderer.invoke("shell:graphics"),
|
||||
// The window's processes as type and two sizes, for the memory a report may carry.
|
||||
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
|
||||
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
|
||||
// A pane and a path inside its workspace; main asks the kernel where that is.
|
||||
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
|
||||
const TYPES = { Tab: "renderer", Browser: "browser", GPU: "gpu" };
|
||||
// The kernel adds its own row, and a report holds a dozen.
|
||||
const MAX_ROWS = 11;
|
||||
|
||||
const kb = (v) => (Number.isFinite(v) && v > 0 ? Math.min(Math.round(v), 2 ** 31) : 0);
|
||||
|
||||
// What app.getAppMetrics says, cut down to what a report may carry: a fixed
|
||||
// type per process and two whole numbers. No pid, name, path or command line
|
||||
// is read, so none can be sent.
|
||||
function processMemoryRows(metrics) {
|
||||
return (Array.isArray(metrics) ? metrics : []).slice(0, MAX_ROWS).map((m) => ({
|
||||
process: TYPES[m?.type] ?? "utility",
|
||||
workingSetKB: kb(m?.memory?.workingSetSize),
|
||||
privateKB: kb(m?.memory?.privateBytes),
|
||||
}));
|
||||
}
|
||||
|
||||
function processMemoryHandler(app, fromWindow) {
|
||||
return (event) => {
|
||||
if (!fromWindow(event)) return [];
|
||||
try {
|
||||
return processMemoryRows(app.getAppMetrics());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { processMemoryRows, processMemoryHandler };
|
||||
@@ -19,10 +19,12 @@ function trayAsset(scaleFactor) {
|
||||
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
|
||||
}
|
||||
|
||||
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
|
||||
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
|
||||
// platforms choose among the @Nx files themselves.
|
||||
// macOS draws a Template-suffixed pair (black on transparent) in the menu bar's
|
||||
// own tint. Windows rebuilds the tray icon from the 1x bitmap, resizing it to
|
||||
// the small icon metric, so the file is loaded as 1x pixels of exactly that
|
||||
// size. Other platforms choose among the @Nx files themselves.
|
||||
function trayImage(nativeImage, scaleFactor) {
|
||||
if (process.platform === "darwin") return nativeImage.createFromPath(path.join(ASSETS, "trayTemplate.png"));
|
||||
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
|
||||
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
// A page the agent lays out in a viewport of its own (a resize) is shown whole in the panel, not cropped to its
|
||||
// top-left corner: scaled down to fit and centred, with the agent's clicks still landing where they were aimed.
|
||||
// Run with `pnpm browser-fit-live`; it needs no network. SHOTS=<dir> also writes pictures of the window.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
const { checker } = require("./livekit");
|
||||
|
||||
const PAGE = `<!doctype html><title>wide</title><style>body{margin:0;font:20px sans-serif}.bar{display:flex;justify-content:space-between;background:#1a56db;color:#fff;padding:14px;width:1280px;box-sizing:border-box}.c{width:1280px;height:600px;background:linear-gradient(90deg,#fde68a,#a7f3d0);position:relative}.r,.l{position:absolute;top:10px;background:#111;color:#fff;padding:8px}.r{right:10px}.l{left:10px;top:60px}</style><div class=bar><b>LEFT edge</b><b>RIGHT edge</b></div><div class=c><span class=r id=rc onclick="document.title='hit'">right column</span><span class=l id=lc onclick="document.title='hit'">left column</span></div>`;
|
||||
// Its own profile, so another Electron run on this machine does not hold this one back.
|
||||
app.setPath("userData", fs.mkdtempSync(path.join(require("node:os").tmpdir(), "rx-fit-live-")));
|
||||
|
||||
const { check, failures } = checker();
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const server = http.createServer((_q, s) => { s.writeHead(200, { "Content-Type": "text/html" }); s.end(PAGE); });
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd;font:14px sans-serif'><div style='position:absolute;left:20px;top:20px'>app chrome</div>");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const page = views.create({ partition: "fit", url: `http://127.0.0.1:${server.address().port}/`, onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
|
||||
const view = views.entries.get(page.targetId).view;
|
||||
await wait(1500);
|
||||
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
|
||||
const shot = (name) => {
|
||||
if (!process.env.SHOTS) return;
|
||||
fs.mkdirSync(process.env.SHOTS, { recursive: true });
|
||||
win.focus();
|
||||
const b = win.getBounds();
|
||||
execFileSync("screencapture", ["-x", "-R", `${b.x},${b.y},${b.width},${b.height}`, `${process.env.SHOTS}/${name}.png`]);
|
||||
};
|
||||
const click = async (id = "rc") => {
|
||||
await evalIn("document.title='reset'");
|
||||
const r = JSON.parse(await evalIn(`JSON.stringify(document.getElementById('${id}').getBoundingClientRect())`));
|
||||
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
|
||||
await page.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
|
||||
}
|
||||
await wait(250);
|
||||
return evalIn("document.title");
|
||||
};
|
||||
const panel = { x: 380, y: 60, width: 560, height: 620 };
|
||||
views.show(page.targetId, panel);
|
||||
await wait(600);
|
||||
check("no resize: the page fills the panel as before", JSON.stringify(view.getBounds()) === JSON.stringify(panel));
|
||||
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
const fit = view.getBounds();
|
||||
check("a 1280x900 viewport in a 560x620 panel: the view is scaled to 560x394 and centred", fit.width === 560 && fit.height === 394 && fit.x === 380 && fit.y === 173, JSON.stringify(fit));
|
||||
check("the page still lays out at the viewport it was given", (await evalIn("innerWidth")) === 1280);
|
||||
check("an agent click aimed at the right column lands on it", (await click()) === "hit");
|
||||
shot("fit-in-panel");
|
||||
|
||||
views.show(page.targetId, { x: 380, y: 60, width: 400, height: 500 });
|
||||
await wait(600);
|
||||
const narrower = view.getBounds();
|
||||
check("a narrower panel re-fits the view", narrower.width === 400 && narrower.height === 281, JSON.stringify(narrower));
|
||||
check("and the click still lands", (await click()) === "hit");
|
||||
|
||||
views.show(page.targetId, { x: 20, y: 20, width: 1400, height: 1000 });
|
||||
await wait(600);
|
||||
const roomy = view.getBounds();
|
||||
check("a panel larger than the viewport shows it at full size, centred, never enlarged", roomy.width === 1280 && roomy.height === 900 && roomy.x === 80 && roomy.y === 70, JSON.stringify(roomy));
|
||||
check("and the click lands at full size", (await click()) === "hit");
|
||||
|
||||
views.hide();
|
||||
await wait(600);
|
||||
check("parked, the page is not scaled and the click lands", (await click()) === "hit");
|
||||
|
||||
views.show(page.targetId, panel);
|
||||
await wait(600);
|
||||
check("shown again it is fitted again", view.getBounds().width === 560 && (await click()) === "hit");
|
||||
|
||||
const small = { x: 20, y: 20, width: 900, height: 700 };
|
||||
views.show(page.targetId, small);
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 600, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
const phone = view.getBounds();
|
||||
check("a viewport smaller than the panel is shown at its own size, centred, not in the panel's corner", phone.width === 390 && phone.height === 600 && phone.x === 20 + (900 - 390) / 2 && phone.y === 20 + (700 - 600) / 2, JSON.stringify(phone));
|
||||
check("and a click on it lands", (await click("lc")) === "hit");
|
||||
views.show(page.targetId, panel);
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
const taken = await page.send("Page.captureScreenshot", { format: "png" });
|
||||
const png = Buffer.from(taken.data, "base64");
|
||||
check("the agent's screenshot is the size of the viewport it asked for", png.readUInt32BE(16) === 1280 && png.readUInt32BE(20) === 900, `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`);
|
||||
await wait(600);
|
||||
check("after the agent takes a screenshot the page is still fitted and a click still lands", (await click()) === "hit");
|
||||
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 0, height: 0, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
check("a zero-sized override is no override: the page fills the panel and a click lands", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560 && (await click("lc")) === "hit", JSON.stringify(view.getBounds()));
|
||||
|
||||
await page.send("Emulation.clearDeviceMetricsOverride", {});
|
||||
await wait(600);
|
||||
check("clearing the viewport gives the page its panel back", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560);
|
||||
shot("cleared");
|
||||
|
||||
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
// Local html previews, an oversized image document and a PDF in the built-in browser, each with and without an
|
||||
// agent viewport: the view must be filled by the page, measured on the pixels, not just sized right.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
const { checker } = require("./livekit");
|
||||
|
||||
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-fit-pages-")));
|
||||
const { check, failures } = checker();
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const PANEL = { x: 380, y: 60, width: 560, height: 620 };
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-pages-"));
|
||||
|
||||
function png(w, h, rgb) {
|
||||
const zlib = require("node:zlib");
|
||||
const crc = (buf) => { let c, t = []; for (let n = 0; n < 256; n++) { c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } let r = 0xffffffff; for (const b of buf) r = t[(r ^ b) & 255] ^ (r >>> 8); return (r ^ 0xffffffff) >>> 0; };
|
||||
const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(body)); return Buffer.concat([len, body, c]); };
|
||||
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
||||
const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: w }, () => rgb).flat())]);
|
||||
return Buffer.concat([Buffer.from("\x89PNG\r\n\x1a\n", "binary"), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row)))), chunk("IEND", Buffer.alloc(0))]);
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, "big.png"), png(3200, 1800, [37, 99, 235]));
|
||||
fs.copyFileSync("/tmp/fl/preview.html", path.join(dir, "preview.html"));
|
||||
fs.copyFileSync("/tmp/fl/page.pdf", path.join(dir, "page.pdf"));
|
||||
|
||||
// Share of the view's sampled pixels that are the near-white or light-grey empty background of an unfilled view.
|
||||
async function emptyShare(view) {
|
||||
const img = await view.webContents.capturePage();
|
||||
const { width, height } = img.getSize();
|
||||
const bm = img.toBitmap();
|
||||
let empty = 0, blue = 0, n = 0;
|
||||
for (let y = 2; y < height - 2; y += Math.max(1, Math.floor(height / 40))) {
|
||||
for (let x = 2; x < width - 2; x += Math.max(1, Math.floor(width / 40))) {
|
||||
const i = (y * width + x) * 4;
|
||||
const b = bm[i], g = bm[i + 1], r = bm[i + 2];
|
||||
n++;
|
||||
if (Math.min(r, g, b) > 225 && Math.max(r, g, b) - Math.min(r, g, b) < 12) empty++;
|
||||
if (b > 180 && r < 90 && g < 150) blue++;
|
||||
}
|
||||
}
|
||||
return { share: empty / n, blue: blue / n, width, height };
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd'>app");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const cases = [
|
||||
["local html preview, no override", "preview.html", null],
|
||||
["local html preview, 1280x900 override", "preview.html", { width: 1280, height: 900 }],
|
||||
["local html preview, oversized 3200x1800 override", "preview.html", { width: 3200, height: 1800 }],
|
||||
["3200x1800 image document, no override", "big.png", null],
|
||||
["3200x1800 image document, 3200x1800 override", "big.png", { width: 3200, height: 1800 }],
|
||||
["PDF, no override", "page.pdf", null],
|
||||
["PDF, 1280x900 override", "page.pdf", { width: 1280, height: 900 }],
|
||||
];
|
||||
for (const [name, file, override] of cases) {
|
||||
const page = views.create({ partition: "p" + Math.random(), url: "file://" + path.join(dir, file), onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
|
||||
const entry = views.entries.get(page.targetId);
|
||||
await wait(file.endsWith(".pdf") ? 3500 : 1500);
|
||||
views.show(page.targetId, PANEL);
|
||||
await wait(500);
|
||||
if (override) await page.send("Emulation.setDeviceMetricsOverride", { ...override, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(file.endsWith(".pdf") ? 2500 : 1200);
|
||||
const at = entry.view.getBounds();
|
||||
if (process.env.SHOTS) { fs.mkdirSync(process.env.SHOTS, { recursive: true }); fs.writeFileSync(path.join(process.env.SHOTS, name.replace(/[^a-z0-9]+/gi, "-") + ".png"), (await entry.view.webContents.capturePage()).toPNG()); }
|
||||
const m = await emptyShare(entry.view);
|
||||
const inside = at.x >= PANEL.x && at.y >= PANEL.y && at.x + at.width <= PANEL.x + PANEL.width && at.y + at.height <= PANEL.y + PANEL.height;
|
||||
const shown = file.endsWith(".html") ? m.share < 0.15 : m.share < 0.15 && m.blue >= (file.endsWith(".png") ? 0.5 : 0.05);
|
||||
const detail = `view ${JSON.stringify(at)}, empty ${(m.share * 100).toFixed(0)}%, page-coloured ${(m.blue * 100).toFixed(0)}% of ${m.width}x${m.height}`;
|
||||
console.log(" measured:", name, detail);
|
||||
check(`${name}: the view sits inside the panel and the page is drawn in it`, inside && shown, detail);
|
||||
views.close?.(page.targetId);
|
||||
}
|
||||
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
"use strict";
|
||||
// A page the agent lays out at a viewport wider than the pane has to stay whole for the
|
||||
// person watching: the view shows all of it, scaled to the pane, not its left part.
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app } = require("electron");
|
||||
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
|
||||
|
||||
const PAGE = `<!doctype html><title>Wide</title><body style="margin:0">
|
||||
<div style="width:100vw;height:100vh;background:linear-gradient(90deg,#e33,#33e);color:#fff;font:48px sans-serif">
|
||||
<span style="position:absolute;left:12px;top:12px">LEFT</span>
|
||||
<span style="position:absolute;right:12px;top:12px">RIGHT</span>
|
||||
<button id="go" style="position:absolute;right:40px;top:300px;font:32px sans-serif" onclick="document.title='clicked'">Go</button></div>`;
|
||||
|
||||
const { check, failures } = checker();
|
||||
const SIZE = { width: 1440, height: 1000 };
|
||||
|
||||
async function main() {
|
||||
const page = await listen((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(PAGE);
|
||||
});
|
||||
let calls = 0;
|
||||
const { handler, requests } = scriptedModel((tools) => {
|
||||
calls++;
|
||||
if (calls === 1) return { name: "browser_open", arguments: { url: `${page.url}/` } };
|
||||
if (calls === 2) return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "resize", ...SIZE }] } };
|
||||
if (calls === 3) return { name: "browser_read", arguments: { tab: "t1", what: "screenshot" } };
|
||||
if (calls === 4) {
|
||||
const ref = (tools.join("\n").match(/button "Go" \[(e\d+)\]/) || [])[1];
|
||||
return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "click", ref }] } };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const model = await listen(handler);
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-live-"));
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-ws-"));
|
||||
process.env.REASONIX_HOME = home;
|
||||
seedHome(home, model.url, workspace);
|
||||
const { current } = require("../src/main.js");
|
||||
|
||||
try {
|
||||
const win = await settledWindow();
|
||||
const { client } = current();
|
||||
const runtimes = await until("a pane", async () => {
|
||||
const list = await client.json("GET", "/runtimes");
|
||||
return Array.isArray(list) && list.length ? list : null;
|
||||
});
|
||||
await client.request("POST", `${runtimes[0].base}/submit`, { input: "open the wide page and lay it out wide" });
|
||||
await until("the scripted turn", async () => requests.length >= 5, 60000);
|
||||
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
|
||||
const guest = await until("a page view", async () => guests()[0]);
|
||||
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null));
|
||||
await wait(1500);
|
||||
const bounds = guest.getBounds();
|
||||
const inner = await guest.webContents.executeJavaScript("[innerWidth, innerHeight]");
|
||||
check("the page is laid out at the viewport the agent asked for", inner[0] === SIZE.width && inner[1] === SIZE.height, inner);
|
||||
const draw = (name) => {
|
||||
const file = path.join(os.tmpdir(), name);
|
||||
try {
|
||||
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], file]);
|
||||
process.stdout.write(` screenshot: ${file}\n`);
|
||||
} catch (err) {
|
||||
process.stdout.write(` (no screenshot: ${err.message})\n`);
|
||||
}
|
||||
};
|
||||
const shown = await guest.webContents.capturePage();
|
||||
const size = shown.getSize();
|
||||
const px = shown.toBitmap();
|
||||
const at = (x, y) => { const i = (y * size.width + x) * 4; return { b: px[i], g: px[i + 1], r: px[i + 2] }; };
|
||||
const edge = at(size.width - 6, 6);
|
||||
check("the right edge of the page is what the view draws at its right edge", edge.b > edge.r, edge);
|
||||
check("the agent's click on a control at the right of the page landed", (await guest.webContents.getTitle()) === "clicked", await guest.webContents.getTitle());
|
||||
draw("rx-resize-live.png");
|
||||
const taken = await guest.webContents.debugger.sendCommand("Page.captureScreenshot", { format: "png" });
|
||||
const dims = require("electron").nativeImage.createFromBuffer(Buffer.from(taken.data, "base64")).getSize();
|
||||
process.stdout.write(` agent screenshot ${JSON.stringify(dims)}\n`);
|
||||
check("a screenshot taken the way the agent takes one shows the page at the size it asked for", dims.width === SIZE.width && dims.height === SIZE.height, dims);
|
||||
process.stdout.write(` bounds ${JSON.stringify(bounds)} inner ${JSON.stringify(inner)}\n`);
|
||||
} catch (err) {
|
||||
check("the run completed", false, String(err && err.message ? err.message : err));
|
||||
}
|
||||
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
}
|
||||
|
||||
app.whenReady().then(main);
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
// With the project folder gone, a tool call is refused with the typed cause and the
|
||||
// window says so above the composer, with a way to choose the folder again.
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app } = require("electron");
|
||||
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
|
||||
|
||||
const { check, failures } = checker();
|
||||
|
||||
async function main() {
|
||||
let calls = 0;
|
||||
const { handler, requests } = scriptedModel(() => {
|
||||
calls++;
|
||||
if (calls === 1) return { name: "grep", arguments: { pattern: "hello" } };
|
||||
return null;
|
||||
});
|
||||
const model = await listen(handler);
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-live-"));
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-ws-"));
|
||||
process.env.REASONIX_HOME = home;
|
||||
seedHome(home, model.url, workspace);
|
||||
const { current } = require("../src/main.js");
|
||||
|
||||
try {
|
||||
const win = await settledWindow();
|
||||
const { client } = current();
|
||||
const runtimes = await until("a pane", async () => {
|
||||
const list = await client.json("GET", "/runtimes");
|
||||
return Array.isArray(list) && list.length ? list : null;
|
||||
});
|
||||
process.chdir(os.tmpdir());
|
||||
const gone = `${workspace}-moved`;
|
||||
fs.renameSync(workspace, gone);
|
||||
await client.request("POST", `${runtimes[0].base}/submit`, { input: "look for hello" });
|
||||
await until("the scripted turn", async () => requests.length >= 2, 60000);
|
||||
const result = String((requests[1].messages || []).filter((m) => m.role === "tool").at(-1)?.content || "");
|
||||
check("the model was told the typed cause, not a bare system error", result.startsWith("root missing: the project folder ") && result.includes("not a permission or argument problem"), result.slice(0, 200));
|
||||
const js = (src) => win.webContents.executeJavaScript(src);
|
||||
const banner = await until("the banner above the composer", async () => js(`(() => { const b = document.querySelector('[data-cause="root-missing"]'); return b ? b.textContent : null; })()`), 20000).catch(() => null);
|
||||
check("the window shows the banner", !!banner, banner);
|
||||
check("the banner offers to choose the folder again", await js(`!!document.querySelector('[data-action="root-missing.reselect"]')`));
|
||||
await wait(3000);
|
||||
process.stdout.write(` page: ${await js("document.body.innerText.slice(0, 300).replace(/\\n/g, ' | ')")}\n`);
|
||||
const png = path.join(os.tmpdir(), "rx-root-missing-live.png");
|
||||
try {
|
||||
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], png]);
|
||||
process.stdout.write(` screenshot: ${png}\n`);
|
||||
} catch (err) {
|
||||
process.stdout.write(` (no screenshot: ${err.message})\n`);
|
||||
}
|
||||
fs.renameSync(gone, workspace);
|
||||
} catch (err) {
|
||||
check("the run completed", false, String(err && err.message ? err.message : err));
|
||||
}
|
||||
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
}
|
||||
|
||||
app.whenReady().then(main);
|
||||
@@ -1573,6 +1573,26 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
|
||||
}
|
||||
});
|
||||
|
||||
test("the launch credential cookie is put back when something removes it, and only then", async () => {
|
||||
const { keepCredential } = require("../src/credential.js");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const jar = new EventEmitter();
|
||||
const sets = [];
|
||||
const ses = { cookies: Object.assign(jar, { set: async (c) => void sets.push(c) }) };
|
||||
const cookie = { url: "http://127.0.0.1:5000/", name: "reasonix_token", value: "t" };
|
||||
const lines = [];
|
||||
keepCredential(ses, cookie, (l) => lines.push(l));
|
||||
const gone = { name: "reasonix_token", domain: "127.0.0.1" };
|
||||
jar.emit("changed", {}, { ...gone, name: "other" }, "explicit", true);
|
||||
jar.emit("changed", {}, { ...gone, domain: "example.com" }, "explicit", true);
|
||||
jar.emit("changed", {}, gone, "overwrite", true);
|
||||
jar.emit("changed", {}, gone, "explicit", false);
|
||||
assert.equal(sets.length, 0, "an unrelated change must not touch the jar");
|
||||
jar.emit("changed", {}, gone, "evicted", true);
|
||||
assert.deepEqual(sets, [cookie]);
|
||||
assert.match(lines[0], /evicted/);
|
||||
});
|
||||
|
||||
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
|
||||
const { trayAsset, SCALES } = require("../src/trayimage.js");
|
||||
const dim = (f) => {
|
||||
@@ -1590,6 +1610,25 @@ test("the tray has a file for every Windows scale, at exactly 16 * scale pixels"
|
||||
assert.equal(trayAsset(NaN).pixels, 16);
|
||||
});
|
||||
|
||||
test("the macOS tray is a black-on-transparent Template pair, not the colour badge", () => {
|
||||
const { trayImage } = require("../src/trayimage.js");
|
||||
const asked = [];
|
||||
const nativeImage = { createFromPath: (f) => (asked.push(f), { isEmpty: () => false }) };
|
||||
const platform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
trayImage(nativeImage, 2);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", platform);
|
||||
}
|
||||
assert.match(path.basename(asked[0]), /^trayTemplate\.png$/);
|
||||
for (const [file, px] of [["trayTemplate.png", 18], ["[email protected]", 36]]) {
|
||||
const b = fs.readFileSync(path.join(path.dirname(asked[0]), file));
|
||||
assert.deepEqual([b.readUInt32BE(16), b.readUInt32BE(20)], [px, px], file);
|
||||
assert.equal(b[25], 6, `${file} must carry an alpha channel`);
|
||||
}
|
||||
});
|
||||
|
||||
function pe(machine) {
|
||||
const b = Buffer.alloc(0x100);
|
||||
b.writeUInt16LE(0x5a4d, 0);
|
||||
@@ -2281,6 +2320,115 @@ test("a parked page is attached again when it is shown, and a page already on sc
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
|
||||
});
|
||||
|
||||
test("a page laid out wider than the pane is scaled to fit it, and keeps the viewport the agent asked for", async () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let BrowserViews;
|
||||
try {
|
||||
({ BrowserViews } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => false,
|
||||
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView() {}, removeChildView() {} },
|
||||
};
|
||||
const sent = [];
|
||||
const view = {
|
||||
setBounds() {},
|
||||
webContents: { isDestroyed: () => false, debugger: { sendCommand: async (method, params) => sent.push([method, params]) } },
|
||||
};
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const entry = { view, at: null, attached: true };
|
||||
views.entries.set("t1", entry);
|
||||
views.keepKeyboard = () => {};
|
||||
entry.override = { width: 1440, height: 1000, deviceScaleFactor: 0, mobile: false };
|
||||
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0][1].scale, 0.5, "a 1440 wide layout in a 720 wide pane is halved");
|
||||
assert.equal(sent[0][1].width, 1440, "the layout viewport is still the one the agent asked for");
|
||||
|
||||
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
|
||||
assert.equal(sent.length, 1, "the same pane sends nothing again");
|
||||
|
||||
views.show("t1", { x: 0, y: 0, width: 360, height: 800 });
|
||||
assert.equal(sent.at(-1)[1].scale, 0.25, "a narrower pane scales further");
|
||||
|
||||
views.show("t1", { x: 0, y: 0, width: 2000, height: 1200 });
|
||||
assert.equal(sent.at(-1)[1].scale, 1, "a pane larger than the layout never enlarges it");
|
||||
|
||||
const settled = sent.length;
|
||||
views.fit(entry);
|
||||
assert.equal(sent.length, settled, "an unchanged scale is not sent again");
|
||||
views.fit(entry, true);
|
||||
assert.equal(sent.length, settled + 1, "a capture that dropped the scale gets it back");
|
||||
assert.equal(sent.at(-1)[1].scale, 1);
|
||||
entry.override = undefined;
|
||||
const before = sent.length;
|
||||
views.show("t1", { x: 0, y: 0, width: 500, height: 800 });
|
||||
assert.equal(sent.length, before, "with no override there is nothing to fit");
|
||||
});
|
||||
|
||||
test("a page given its own viewport is fitted whole into its panel, centred, and never enlarged", () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let placement;
|
||||
try {
|
||||
({ placement } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const panel = { x: 380, y: 60, width: 560, height: 620 };
|
||||
assert.deepEqual(placement(panel, null), { at: panel, scale: 1 }, "a page without a viewport of its own fills the panel");
|
||||
for (const none of [{ width: 0, height: 0 }, { width: 0, height: 600 }, { width: 390, height: 0 }, { width: NaN, height: 600 }, { width: 390, height: Infinity }, { width: -1, height: 600 }]) {
|
||||
assert.deepEqual(placement(panel, none), { at: panel, scale: 1 }, `${JSON.stringify(none)} is no viewport at all`);
|
||||
}
|
||||
const wide = placement(panel, { width: 1280, height: 900 });
|
||||
assert.equal(wide.scale, 0.4375);
|
||||
assert.deepEqual(wide.at, { x: 380, y: 173, width: 560, height: 394 });
|
||||
const phone = placement(panel, { width: 390, height: 844 });
|
||||
assert.ok(phone.scale < 1 && phone.at.height <= panel.height && phone.at.width <= panel.width);
|
||||
assert.deepEqual(placement({ x: 20, y: 20, width: 1400, height: 1000 }, { width: 1280, height: 900 }), { at: { x: 80, y: 70, width: 1280, height: 900 }, scale: 1 });
|
||||
});
|
||||
|
||||
test("input that carries a point is scaled with the page or refused, never sent to the wrong place", () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let scaleInput;
|
||||
try {
|
||||
({ scaleInput } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const mouse = { type: "mousePressed", x: 1200, y: 800, button: "left" };
|
||||
assert.equal(scaleInput("Input.dispatchMouseEvent", mouse, 1), mouse, "an unscaled page gets the point as it is");
|
||||
assert.deepEqual(scaleInput("Input.dispatchMouseEvent", mouse, 0.5), { type: "mousePressed", x: 600, y: 400, button: "left" });
|
||||
for (const method of ["Input.dispatchTouchEvent", "Input.dispatchDragEvent", "Input.emulateTouchFromMouseEvent", "Input.synthesizeTapGesture", "Input.synthesizeScrollGesture", "Input.synthesizePinchGesture"]) {
|
||||
const params = { x: 10, y: 20, touchPoints: [{ x: 10, y: 20 }] };
|
||||
assert.equal(scaleInput(method, params, 1), params, `${method} passes on an unscaled page`);
|
||||
assert.throws(() => scaleInput(method, params, 0.5), { code: "input_unscaled" }, `${method} must not reach a scaled page unscaled`);
|
||||
}
|
||||
const key = { type: "keyDown", key: "a" };
|
||||
assert.equal(scaleInput("Input.dispatchKeyEvent", key, 0.5), key, "keys carry no point");
|
||||
assert.equal(scaleInput("Input.insertText", { text: "x" }, 0.5).text, "x");
|
||||
});
|
||||
|
||||
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
|
||||
const file = path.join(dir, "prefs.json");
|
||||
@@ -2759,3 +2907,39 @@ test("a window that fails to raise is given its opacity and mouse back", async (
|
||||
await assert.rejects(wakeOf(win).run(async () => {}), /no window/);
|
||||
assert.deepEqual(calls.slice(-3), ["ignore false", "opacity 1", "minimize"]);
|
||||
});
|
||||
|
||||
const { processMemoryRows, processMemoryHandler } = require("../src/procmem.js");
|
||||
|
||||
test("the shell's process memory is only a fixed type and two whole numbers per process", () => {
|
||||
const metrics = [
|
||||
{ pid: 4740, type: "Tab", name: "C:\\Users\\someone\\x", cmd: "--user-data-dir=/home/someone", memory: { workingSetSize: 3992908, privateBytes: 3900000, peakWorkingSetSize: 9 } },
|
||||
{ pid: 11, type: "Browser", memory: { workingSetSize: 120000, privateBytes: 90000 } },
|
||||
{ pid: 12, type: "GPU", memory: { workingSetSize: 76000 } },
|
||||
{ pid: 13, type: "Utility", memory: { workingSetSize: 6000, privateBytes: 5000 } },
|
||||
{ pid: 14, type: "Zygote", memory: { workingSetSize: 1000, privateBytes: 900 } },
|
||||
{ pid: 15, type: "Tab", memory: { workingSetSize: Number.NaN, privateBytes: -4 } },
|
||||
];
|
||||
const rows = processMemoryRows(metrics);
|
||||
assert.deepEqual(rows, [
|
||||
{ process: "renderer", workingSetKB: 3992908, privateKB: 3900000 },
|
||||
{ process: "browser", workingSetKB: 120000, privateKB: 90000 },
|
||||
{ process: "gpu", workingSetKB: 76000, privateKB: 0 },
|
||||
{ process: "utility", workingSetKB: 6000, privateKB: 5000 },
|
||||
{ process: "utility", workingSetKB: 1000, privateKB: 900 },
|
||||
{ process: "renderer", workingSetKB: 0, privateKB: 0 },
|
||||
]);
|
||||
assert.ok(rows.every((r) => Object.keys(r).sort().join() === "privateKB,process,workingSetKB"));
|
||||
assert.doesNotMatch(JSON.stringify(rows), /Users|pid|user-data|cmd/i);
|
||||
});
|
||||
|
||||
test("the shell reports at most eleven processes, since the kernel adds its own row to a dozen", () => {
|
||||
const many = Array.from({ length: 30 }, (_, i) => ({ pid: i, type: "Utility", memory: { workingSetSize: 1, privateBytes: 1 } }));
|
||||
assert.equal(processMemoryRows(many).length, 11);
|
||||
});
|
||||
|
||||
test("only the app's own window is answered, and nothing is read from the asker", () => {
|
||||
const app = { getAppMetrics: () => [{ type: "Tab", memory: { workingSetSize: 5, privateBytes: 4 } }] };
|
||||
assert.deepEqual(processMemoryHandler(app, () => true)({}), [{ process: "renderer", workingSetKB: 5, privateKB: 4 }]);
|
||||
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
|
||||
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
|
||||
});
|
||||
@@ -39,8 +39,8 @@
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.19.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-map-gl": "8.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "3.10.1",
|
||||
@@ -56,14 +56,14 @@
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^8.0.7",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsdom": "^30.1.2",
|
||||
"playwright": "^1.64.0",
|
||||
"jsdom": "^30.1.3",
|
||||
"playwright": "^1.64.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.3",
|
||||
"vitest": "^5.0.3"
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.4"
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶
|
||||
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
|
||||
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
|
||||
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
|
||||
@@ -19,11 +19,12 @@ for (const scheme of ["light", "dark"]) {
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".machpick");
|
||||
await page.waitForSelector('.projects-heading [data-action="workspace.add"]');
|
||||
const current = () => page.evaluate(() => [...document.querySelectorAll(".wsnode[data-current]")].map((el) => el.textContent ?? ""));
|
||||
const was = await current();
|
||||
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-before.png`) });
|
||||
await page.locator(".machpick").first().click();
|
||||
await page.locator('.projects-heading [data-action="workspace.add"]').click();
|
||||
await page.getByRole("dialog", { name: "打开项目", exact: true }).getByRole("button", { name: "选择本机文件夹", exact: true }).click();
|
||||
await page.waitForFunction(() => [...document.querySelectorAll(".wsnode[data-current]")].some((el) => el.textContent?.includes("mock-workspace")), null, { timeout: 5000 }).catch(() => {});
|
||||
const now = await current();
|
||||
check(`${scheme}: the added project becomes the current one`, now.length === 1 && now[0].includes("mock-workspace"), now.join("|").slice(0, 120));
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// The "+" that opens a project folder sits on the machine row where a person
|
||||
// looks for it, and is there before the row is hovered. It was drawn only on
|
||||
// hover once, and two people on 2.39.0 reported that they could not choose a
|
||||
// folder at all. Opacity and hit-testing need a real browser.
|
||||
// The Projects heading's add-folder button stays visible and hit-testable
|
||||
// before hover. Opacity and hit-testing need a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
|
||||
@@ -18,11 +16,11 @@ try {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".machrow[data-here]");
|
||||
await page.waitForSelector(".projects-heading");
|
||||
await page.mouse.move(width - 2, 790);
|
||||
await page.waitForTimeout(300);
|
||||
const g = await page.evaluate(() => {
|
||||
const row = document.querySelector(".machrow[data-here]");
|
||||
const row = document.querySelector(".projects-heading");
|
||||
const add = row?.querySelector('[data-action="workspace.add"]');
|
||||
if (!add) return null;
|
||||
const r = add.getBoundingClientRect();
|
||||
@@ -32,7 +30,7 @@ try {
|
||||
return { opacity: seen, width: r.width, height: r.height, hits: hit === add || add.contains(hit), shown: row.matches(":hover") };
|
||||
});
|
||||
const where = `${theme} ${width}`;
|
||||
check(`${where}: the machine row has the add-folder button`, g !== null);
|
||||
check(`${where}: the Projects heading has the add-folder button`, g !== null);
|
||||
if (!g) continue;
|
||||
check(`${where}: it is visible without hovering the row`, !g.shown && g.opacity === 1 && g.width > 0 && g.height > 0, JSON.stringify(g));
|
||||
check(`${where}: it takes the pointer`, g.hits);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// The Git panel in the workbench: a workspace that is one repository shows the changes as a folder tree to stage,
|
||||
// discard and commit, a push that is previewed before it is confirmed, and a history whose commits open into their
|
||||
// files; a folder of projects shows one section per repository, each acting on its own.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shotDir = process.env.SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1400, 700]) {
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const open = async (query) => {
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width, height: 1000 }, locale: "zh-CN", reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&ws=2&sess=3&turns=2${query}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
await page.waitForSelector(".workbench-changes");
|
||||
return page;
|
||||
};
|
||||
const shot = async (page, name) => {
|
||||
if (!shotDir) return;
|
||||
await page.mouse.move(width - 3, 995);
|
||||
await page.waitForTimeout(250);
|
||||
const box = await page.locator(".workbench-explorer").first().boundingBox();
|
||||
// At phone width the composer's toolbar is drawn over the foot of the column; the panel's own picture stops above it.
|
||||
await page.screenshot({ path: `${shotDir}/${scheme}-${width}-${name}.png`, clip: { x: box.x, y: box.y, width: box.width, height: box.height - (width <= 700 ? 150 : 0) } });
|
||||
};
|
||||
|
||||
const one = await open("");
|
||||
const tree = await one.evaluate(() => ({
|
||||
folders: [...document.querySelectorAll(".workbench-change-folder")].length,
|
||||
rows: [...document.querySelectorAll('.workbench-change-row[data-action="workbench.diff"]')].length,
|
||||
overflow: document.querySelector(".workbench-explorer").scrollWidth > document.querySelector(".workbench-explorer").clientWidth + 1,
|
||||
}));
|
||||
check(`${tag}: the changes are a folder tree`, tree.folders >= 2 && tree.rows === 4, JSON.stringify(tree));
|
||||
check(`${tag}: the panel does not push sideways`, !tree.overflow);
|
||||
await shot(one, "changes");
|
||||
await one.locator('[data-action="workbench.discard"]').first().focus();
|
||||
await one.locator('[data-action="workbench.discard"]').first().click();
|
||||
await one.waitForSelector(".workbench-discard-ask");
|
||||
const ask = await one.evaluate(() => document.querySelector(".workbench-discard-ask").textContent);
|
||||
check(`${tag}: a discard asks first and says it cannot be undone`, ask.includes("无法恢复"));
|
||||
await shot(one, "discard");
|
||||
await one.getByRole("button", { name: "取消", exact: true }).first().click();
|
||||
await one.getByRole("button", { name: "推送…" }).click();
|
||||
await one.waitForSelector(".push-card");
|
||||
const push = await one.evaluate(() => document.querySelector(".push-card").textContent);
|
||||
check(`${tag}: the push says where it goes before it goes`, push.includes("github.com") && push.includes("main → origin/main") && push.includes("从不强制推送"), push.slice(0, 120));
|
||||
await shot(one, "push");
|
||||
await one.getByRole("button", { name: "推送到 github.com", exact: false }).last().click();
|
||||
await one.waitForSelector('.push-card[data-phase="done"]');
|
||||
check(`${tag}: a confirmed push reports it`, (await one.locator(".push-card").textContent()).includes("已把 2 个提交推送到 github.com"));
|
||||
await shot(one, "pushed");
|
||||
await one.getByText("提交历史").click();
|
||||
await one.getByRole("button", { name: /show the commit graph/ }).click();
|
||||
await one.getByRole("button", { name: /retry\.go/ }).last().click();
|
||||
await one.waitForSelector(".gh-diff");
|
||||
await shot(one, "history");
|
||||
await one.close();
|
||||
|
||||
const many = await open("&git=multi");
|
||||
const heads = await many.evaluate(() => [...document.querySelectorAll(".workbench-repo-head")].map((h) => h.textContent));
|
||||
check(`${tag}: a folder of projects shows a section per repository`, heads.length === 3 && heads[0].includes("api") && heads[1].includes("feature/login"), heads.join(" | "));
|
||||
await shot(many, "repos");
|
||||
await many.locator('.workbench-repo[data-repo="web"] [data-action="workbench.stage"]').first().click();
|
||||
await many.waitForFunction(() => document.querySelector('.workbench-repo[data-repo="web"] [data-action="workbench.stage"]')?.getAttribute("aria-checked") === "true");
|
||||
check(`${tag}: staging in one repository leaves the others as they were`, (await many.locator('.workbench-repo[data-repo="api"] [data-action="workbench.stage"][aria-checked="true"]').count()) === 1);
|
||||
await many.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -50,10 +50,11 @@ import { App } from "../src/ui/App";
|
||||
import { MockHub } from "../src/port/mock_hub";
|
||||
import { MockPort } from "../src/port/mock";
|
||||
import { fromHistory } from "../src/state/session";
|
||||
import type { AgentPort, Appearance, HistoryMessage, McpEntry } from "../src/port/port";
|
||||
import { HttpError } from "../src/port/port";
|
||||
import type { AgentPort, Appearance, ChipCall, HistoryMessage, McpEntry } from "../src/port/port";
|
||||
import type { UpdateProgress } from "../src/port/version";
|
||||
import type { ThemePack } from "../src/port/look";
|
||||
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
|
||||
import type { RuntimeView, TreeWindow, TreeWorkspace } from "../src/port/hub";
|
||||
import type { WireEvent } from "../src/port/wire";
|
||||
|
||||
// Saves are coalesced (src/ui/trailing.ts), and a fixture answers too fast for
|
||||
@@ -98,6 +99,34 @@ class BenchPort extends MockPort {
|
||||
// ?mcpactions=1 adds a server whose tools are withheld and one that is followed.
|
||||
constructor() {
|
||||
super();
|
||||
// ?peerset=1 starts with session messages on and two projects already allowed through.
|
||||
if (query.has("peerset")) {
|
||||
this.peerSettings = {
|
||||
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn", pending: query.has("pending"), available: true,
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
|
||||
};
|
||||
}
|
||||
// ?git=multi is a folder of projects: three repositories, each with changes of its own.
|
||||
if (query.get("git") === "multi") {
|
||||
this.repoRows = [
|
||||
{ dir: "api", name: "api", branch: "main", detached: false },
|
||||
{ dir: "web", name: "web", branch: "feature/login", detached: false },
|
||||
{ dir: "tools/release", name: "release", branch: "main", detached: false },
|
||||
];
|
||||
this.repoChanges = {
|
||||
api: [
|
||||
{ path: "internal/server/routes.go", status: "M", unstaged: true, insertions: 12, deletions: 3 },
|
||||
{ path: "internal/server/routes_test.go", status: "M", staged: true, insertions: 20, deletions: 0 },
|
||||
{ path: "README.md", status: "??", unstaged: true },
|
||||
],
|
||||
web: [
|
||||
{ path: "src/login/Form.tsx", status: "M", unstaged: true, insertions: 31, deletions: 9 },
|
||||
{ path: "src/login/api.ts", status: "M", staged: true, unstaged: true, insertions: 4, deletions: 1 },
|
||||
],
|
||||
"tools/release": [],
|
||||
};
|
||||
}
|
||||
if (!query.has("mcpactions")) return;
|
||||
(this as unknown as { servers: McpEntry[] }).servers.push(
|
||||
{ name: "notes", state: "ready", enabled: true, transport: "stdio", tools: 2, description: "Notes the agent can search and add to.",
|
||||
@@ -106,16 +135,31 @@ class BenchPort extends MockPort {
|
||||
);
|
||||
}
|
||||
|
||||
// ?netshell=1 is a connection over HTTP: a `!` line is refused as the kernel refuses it.
|
||||
async submit(text: string, chips?: ChipCall) {
|
||||
(window.__sent ??= []).push(text);
|
||||
if (query.has("netshell") && text.trimStart().startsWith("!")) throw new HttpError(403, "shell commands are unavailable over HTTP", { code: "shell.unavailable_over_http" });
|
||||
return super.submit(text, chips);
|
||||
}
|
||||
|
||||
// ?checkfail=<name> makes a plain test of that source answer 401.
|
||||
protected async answerCheck(name: string) {
|
||||
if (query.get("checkfail") !== name) return super.answerCheck(name);
|
||||
return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
|
||||
}
|
||||
|
||||
// ?twoports=1: two local servers on one host, told apart by port, sharing a key slot.
|
||||
// ?badprice=1: DeepSeek's flash price is a card the vendor's table does not have,
|
||||
// under a pinned display currency.
|
||||
async providers() {
|
||||
const all = await super.providers();
|
||||
if (query.has("twoports")) {
|
||||
const local = (name: string, port: number) => ({
|
||||
...all[0], name, baseUrl: `http://127.0.0.1:${port}/v1`, keyEnv: "LOCAL_API_KEY", inUse: false, preset: false,
|
||||
models: [`${name}-chat`], default: `${name}-chat`,
|
||||
});
|
||||
return [...all, local("local", 8756), local("local-2", 8788)];
|
||||
}
|
||||
if (!query.has("badprice")) return all;
|
||||
return all.map((p) => p.name === "deepseek"
|
||||
? { ...p, officialPricing: true, modelPrices: { "deepseek-flash": { input: 0.3, output: 1.2, cacheHit: 0, currency: "USD", official: false } } }
|
||||
@@ -226,15 +270,6 @@ class BenchPort extends MockPort {
|
||||
return { ok: false, code: `search.searxng_${answer}`, status: answer === "status" ? 429 : answer === "json_disabled" ? 403 : undefined };
|
||||
}
|
||||
|
||||
async peersSettings() {
|
||||
if (!query.has("peerset")) return super.peersSettings();
|
||||
return {
|
||||
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn" as const, pending: query.has("pending"), available: true,
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
|
||||
};
|
||||
}
|
||||
|
||||
async foldWorkSettings() {
|
||||
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
|
||||
}
|
||||
@@ -324,7 +359,7 @@ class BenchPort extends MockPort {
|
||||
const goalRuntime = usage
|
||||
? { turnsUsed: 4, tokensUsed: 128_400, requestsUsed: 9, workDurationMs: 12 * 60_000, noProgressTurns: usage === "blocked" ? 2 : 0, ...(usage === "blocked" ? { stopCause: "goal_stuck", lastReason: "测试一直没有通过,改动没有让失败的数量减少" } : {}) }
|
||||
: undefined;
|
||||
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}) };
|
||||
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
|
||||
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
|
||||
if (!JOBS) return st;
|
||||
const jobs = Array.from({ length: JOBS }, (_, i) => ({
|
||||
@@ -395,6 +430,7 @@ const WORKSPACES = Number(query.get("ws") ?? 0);
|
||||
const PREF = query.get("pref");
|
||||
const ONBOARDING = query.has("onboarding");
|
||||
const SESSIONS = Number(query.get("sess") ?? 0);
|
||||
const ROW_TITLES = ["rx home目录里有没有 obsidian 的笔记仓库,帮我整理一下", "obsidian 插件同步方案怎么选", "查一下 studio 最近一次发布改了什么", "阅读一下最近的提交记录并总结"];
|
||||
// ?turns= is how long the conversation being opened already is.
|
||||
const TURNS = Number(query.get("turns") ?? 0);
|
||||
const STATUS_MS = Number(query.get("statusms") ?? 0);
|
||||
@@ -455,9 +491,26 @@ class BenchHub extends MockHub {
|
||||
return super.moveSessionTo(path, target);
|
||||
}
|
||||
|
||||
tree(): Promise<TreeWorkspace[]> {
|
||||
// ?slowarchive=<ms>: one project with 27 active and 2127 archived conversations,
|
||||
// whose archived read answers after that many milliseconds.
|
||||
tree(window?: TreeWindow): Promise<TreeWorkspace[]> {
|
||||
const slow = Number(query.get("slowarchive") ?? "");
|
||||
if (slow > 0) {
|
||||
const archived = window?.scope === "archived";
|
||||
const rows = Array.from({ length: archived ? 8 : 6 }, (_, i) => ({
|
||||
path: `/sessions/${archived ? "old" : "new"}-${i}.jsonl`, name: `${archived ? "old" : "new"}-${i}`,
|
||||
title: `${archived ? "归档的" : "活跃的"}会话 ${i + 1}`, turns: i + 1, archived,
|
||||
}));
|
||||
const answer = [{ root: "~/projects/workspace-0", name: "workspace-0", remembered: true, open: true, total: 2154, archived: 2127, remaining: archived ? 2119 : 21, sessions: rows }];
|
||||
return new Promise((resolve) => setTimeout(() => resolve(answer), archived ? slow : 0));
|
||||
}
|
||||
const wait = TREE_AT - performance.now();
|
||||
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
|
||||
if (!WORKSPACES && query.has("trustpending") && PANES > 1) {
|
||||
return super.tree().then((all) => all.map((ws, i) => i > 0 ? ws : { ...ws, sessions: Array.from({ length: PANES }, (_, k) => ({
|
||||
path: `/sessions/pane-${k + 1}.jsonl`, name: `pane-${k + 1}`, title: `并行会话 ${k + 1}`, turns: 3, runtimeId: `r${k + 1}`,
|
||||
})) }));
|
||||
}
|
||||
if (!WORKSPACES) return super.tree();
|
||||
return Promise.resolve(
|
||||
Array.from({ length: WORKSPACES }, (_, w) => ({
|
||||
@@ -468,8 +521,12 @@ class BenchHub extends MockHub {
|
||||
sessions: this.placed(`~/projects/workspace-${w}`, Array.from({ length: SESSIONS }, (_, s) => ({
|
||||
path: `/sessions/w${w}-s${s}.jsonl`,
|
||||
name: `w${w}-s${s}`,
|
||||
title: `第 ${s} 次会话,标题是内核起的一句话`,
|
||||
title: query.has("rows") ? ROW_TITLES[s % ROW_TITLES.length] : `第 ${s} 次会话,标题是内核起的一句话`,
|
||||
turns: (s % 40) + 1,
|
||||
...(query.has("rows") ? {
|
||||
lastActiveAt: Date.now() - (9 + s) * 86_400_000,
|
||||
versions: s % 2 === 1 ? [{ path: `/sessions/w${w}-s${s}-v1.jsonl`, name: `w${w}-s${s}-v1`, title: ROW_TITLES[s % ROW_TITLES.length], turns: 1 }] : undefined,
|
||||
} : {}),
|
||||
}))),
|
||||
})),
|
||||
);
|
||||
@@ -490,6 +547,7 @@ createRoot(document.getElementById("root")!).render(<App hub={hub} />);
|
||||
declare global {
|
||||
interface Window {
|
||||
__moves?: { path: string; target: string }[];
|
||||
__sent?: string[];
|
||||
// Feeds every open pane, so a split-view measurement costs what two live
|
||||
// conversations really cost.
|
||||
__feed: (ev: WireEvent) => void;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { chromium } from "playwright";
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
// Names the demo data itself carries, and names that are written in Chinese on purpose.
|
||||
const FIXTURE = [
|
||||
/并行会话演示/, /上一次的会话/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
|
||||
/并行会话演示/, /上一次的会话/, /站点改版/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
|
||||
/缓存命中率为什么会掉/, /Co-Authored-By/, /Generated with/, /^回复用中文$/, /^跟着用户每条消息的语言走$/, /v2 是从零重写的 Go 内核/,
|
||||
/没有 web,桌面端重做/, /问题里提到了 main-v2/, /构建用 -tags legacy/, /^迁移前的构建方式$/,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// A long session of delegated work keeps the page light: a finished sub-agent's child calls are
|
||||
// not in the document until its card is opened, so node count follows what is open, not what ran.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const AGENTS = 120;
|
||||
const MAX_NODES = Number(process.env.PERF_NODES_MAX ?? 25000);
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?turns=3&pref=zh`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.waitForTimeout(600);
|
||||
const before = await page.evaluate(() => document.querySelectorAll("*").length);
|
||||
await page.evaluate((n) => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
const big = (tag) => Array.from({ length: 60 }, (_, k) => `${tag} line ${k}: matched in internal/pkg${k % 40}/file${k}.go`).join("\n");
|
||||
for (let a = 0; a < n; a++) {
|
||||
const pid = `a${a}`;
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: pid, name: "task", args: JSON.stringify({ description: `子代理 ${a}`, prompt: "查" }) } });
|
||||
for (let c = 0; c < 15; c++) {
|
||||
const id = `${pid}c${c}`, name = c % 3 === 0 ? "bash" : c % 3 === 1 ? "grep" : "read_file";
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify({ command: "x" }), parentId: pid } });
|
||||
window.__feed({ kind: "tool_result", tool: { id, name, args: JSON.stringify({ command: "x" }), output: big(id), parentId: pid, durationMs: 300 } });
|
||||
}
|
||||
window.__feed({ kind: "tool_result", tool: { id: pid, name: "task", args: "", output: "report " + a, durationMs: 9000 } });
|
||||
}
|
||||
}, AGENTS);
|
||||
await page.waitForTimeout(800);
|
||||
const after = await page.evaluate(() => document.querySelectorAll("*").length);
|
||||
const kids = await page.evaluate(() => document.querySelectorAll(".nest-bd .call").length);
|
||||
check(`${scheme}: ${AGENTS} finished sub-agents add ${after - before} nodes, under the ${MAX_NODES} limit`, after < MAX_NODES, `${after} in the page`);
|
||||
check(`${scheme}: none of their child calls is in the document while the cards are closed`, kids === 0, `${kids}`);
|
||||
await page.evaluate(() => { const g = [...document.querySelectorAll("details")].filter((d) => d.querySelector('[data-call="a0"]')).pop(); if (g) g.open = true; });
|
||||
await page.waitForTimeout(400);
|
||||
const shoot = async (name) => {
|
||||
if (!shotDir) return;
|
||||
await page.evaluate(() => document.querySelector('[data-call="a0"]')?.scrollIntoView({ block: "start" }));
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: `${shotDir}/${scheme}-nest-${name}.png`, clip: { x: 360, y: 40, width: 1000, height: 420 } });
|
||||
};
|
||||
await shoot("closed");
|
||||
const opened = await page.evaluate(() => {
|
||||
const d = document.querySelector('[data-call="a0"] details.tool-disclosure');
|
||||
if (!d) return -1;
|
||||
d.open = true;
|
||||
d.dispatchEvent(new Event("toggle"));
|
||||
return 1;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const nowKids = await page.evaluate(() => document.querySelectorAll('[data-call="a0"] .nest-bd .call').length);
|
||||
check(`${scheme}: opening one card mounts exactly its own 15 child calls`, opened === 1 && nowKids === 15, `${nowKids}`);
|
||||
await shoot("open");
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,57 @@
|
||||
// What the page holds of finished tool output is bounded: a long session of large results stays light,
|
||||
// and a card says what it left out.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const RESULTS = Number(process.env.PERF_RESULTS ?? 600);
|
||||
const HEAP_MAX_MB = Number(process.env.PERF_HEAP_MAX ?? 150);
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
|
||||
const browser = await chromium.launch({ args: ["--js-flags=--expose-gc"] });
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
await cdp.send("Performance.enable");
|
||||
await page.goto(`${BASE}?turns=2&pref=zh`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate((n) => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
const body = (i) => Array.from({ length: 300 }, (_, k) => `result ${i} line ${k}: matched in internal/pkg${k % 40}/file${k}.go — some context text`).join("\n");
|
||||
for (let i = 0; i < n; i++) {
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: `r${i}`, name: "bash", args: JSON.stringify({ command: "x" }) } });
|
||||
window.__feed({ kind: "tool_result", tool: { id: `r${i}`, name: "bash", args: JSON.stringify({ command: "x" }), output: body(i), durationMs: 200 } });
|
||||
}
|
||||
}, RESULTS);
|
||||
await page.waitForTimeout(800);
|
||||
await cdp.send("HeapProfiler.collectGarbage");
|
||||
await cdp.send("HeapProfiler.collectGarbage");
|
||||
const heap = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value / 1048576;
|
||||
check(`${scheme}: ${RESULTS} results of about 30 KB leave the page's heap under ${HEAP_MAX_MB} MB`, heap < HEAP_MAX_MB, `${heap.toFixed(1)} MB`);
|
||||
const said = await page.evaluate(() => document.body.textContent.includes("没有放在页面里"));
|
||||
check(`${scheme}: a card says what it left out`, said);
|
||||
if (shotDir) {
|
||||
await page.evaluate(() => {
|
||||
const grp = [...document.querySelectorAll("details")].filter((d) => d.querySelector('[data-call="r599"]')).pop();
|
||||
if (grp) grp.open = true;
|
||||
const card = document.querySelector('[data-call="r599"] details.tool-disclosure');
|
||||
if (card) { card.open = true; card.dispatchEvent(new Event("toggle")); }
|
||||
document.querySelector('[data-call="r599"]')?.scrollIntoView({ block: "start" });
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${shotDir}/${scheme}-output-cap.png`, clip: { x: 360, y: 40, width: 1000, height: 520 } });
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -17,7 +17,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&turns=2&peers=1&peerset=1&pending=1`, { waitUntil: "networkidle" });
|
||||
await page.goto(`${BASE}?pref=zh&turns=2&peers=1&peerset=1&pending=1&ws=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
|
||||
await page.waitForSelector(".peerbar");
|
||||
@@ -37,7 +37,12 @@ for (const scheme of ["light", "dark"]) {
|
||||
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
const navLabels = await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].map((b) => b.textContent));
|
||||
check(`${tag}: session messages have a settings entry of their own`, navLabels.some((l) => l.includes("会话间消息")));
|
||||
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click());
|
||||
await page.waitForTimeout(300);
|
||||
check(`${tag}: and no longer sit at the foot of tools and permissions`, (await page.locator('[role="switch"][aria-label="启用会话间消息"]').count()) === 0);
|
||||
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("会话间消息"))?.click());
|
||||
await page.waitForSelector('[role="switch"][aria-label="启用会话间消息"]', { timeout: 8000 });
|
||||
const s = await page.evaluate(() => {
|
||||
const sw = (n) => document.querySelector(`[role="switch"][aria-label="${n}"]`);
|
||||
@@ -62,11 +67,18 @@ for (const scheme of ["light", "dark"]) {
|
||||
check(`${tag}: a saved switch says the next turn, never at once`, s.pending && !s.immediate);
|
||||
check(`${tag}: the file the switches live in is named`, s.path);
|
||||
check(`${tag}: nothing spills out of the box`, s.within && s.spill <= 1, `${s.spill}px`);
|
||||
const pick = page.getByRole("combobox", { name: "放行这个项目" });
|
||||
const options = await pick.locator("option").count();
|
||||
check(`${tag}: a project can be chosen from those already added`, options >= 1, `${options} choices`);
|
||||
await page.getByRole("button", { name: "放行", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll(".peerreleased li").length === 3);
|
||||
check(`${tag}: allowing it through adds it to the list`, true);
|
||||
if (shotDir && width === 1280) {
|
||||
await page.addStyleTag({ content: ".cfgbad { display: none !important; }" });
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await page.waitForTimeout(250);
|
||||
await page.locator("#set-peers").screenshot({ path: `${shotDir}/${scheme}-settings.png` });
|
||||
await page.locator("#set-peer-projects").screenshot({ path: `${shotDir}/${scheme}-projects.png` });
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// A command that never stops printing does not grow the page: only the end of its output is kept
|
||||
// while it runs, and the card says how much came before.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const KEPT_MAX = 70_000;
|
||||
const CHUNKS = Number(process.env.PERF_CHUNKS ?? 150);
|
||||
const HEAP_MAX_MB = Number(process.env.PERF_HEAP_MAX ?? 60);
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
|
||||
const browser = await chromium.launch({ args: ["--js-flags=--expose-gc"] });
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
await cdp.send("Performance.enable");
|
||||
await page.goto(`${BASE}?turns=2&pref=zh`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "long", name: "bash", args: JSON.stringify({ command: "tail -f build.log" }) } });
|
||||
});
|
||||
for (let i = 0; i < CHUNKS; i++) {
|
||||
await page.evaluate((i) => {
|
||||
const line = `2026-10-19 12:00:00 INFO worker-${i} processing a batch with a fairly long log line ` + "x".repeat(60) + "\n";
|
||||
window.__feed({ kind: "tool_progress", tool: { id: "long", name: "bash", args: "", output: line.repeat(1500) } });
|
||||
}, i);
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
await cdp.send("HeapProfiler.collectGarbage");
|
||||
const heap = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value / 1048576;
|
||||
const shown = await page.evaluate(() => document.querySelector('[data-call="long"] .out')?.textContent?.length ?? -1);
|
||||
const note = await page.evaluate(() => document.body.textContent.includes("此前省略了"));
|
||||
check(`${scheme}: the running card holds only the end of ${((CHUNKS * 1500 * 141) / 1048576).toFixed(0)} MB of output`, shown >= 0 && shown <= KEPT_MAX, `${shown} characters`);
|
||||
check(`${scheme}: it says how much came before`, note);
|
||||
check(`${scheme}: the page's heap stays bounded while it runs`, heap < HEAP_MAX_MB, `${heap.toFixed(1)} MB`);
|
||||
if (shotDir) {
|
||||
await page.evaluate(() => {
|
||||
const card = document.querySelector('[data-call="long"] details.tool-disclosure');
|
||||
if (card) { card.open = true; card.dispatchEvent(new Event("toggle")); }
|
||||
document.querySelector('[data-call="long"]')?.scrollIntoView({ block: "start" });
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: `${shotDir}/${scheme}-progress-tail.png`, clip: { x: 360, y: 80, width: 1000, height: 560 } });
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,37 @@
|
||||
// Two servers on one host and different ports are two connections, each listed under its own address.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?twoports=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 20000 });
|
||||
await page.getByRole("button", { name: "设置", exact: true }).click();
|
||||
await page.getByText("模型服务", { exact: true }).first().click();
|
||||
await page.waitForSelector('[data-action-click="provider.select"]');
|
||||
const text = await page.evaluate(() => [...document.querySelectorAll('[data-action-click="provider.select"]')].map((b) => b.textContent ?? ""));
|
||||
check(`${scheme}: the server on port 8756 is listed`, text.some((t) => t.includes("127.0.0.1:8756")), text.join(" | "));
|
||||
check(`${scheme}: the server on port 8788 is listed beside it`, text.some((t) => t.includes("127.0.0.1:8788")), text.join(" | "));
|
||||
await page.waitForTimeout(900);
|
||||
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-ports.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,109 @@
|
||||
// Frame cost of folding the left rail with 1/3/6 panes that each hold a long transcript. The rail width is
|
||||
// animated per frame, so every mounted pane is laid out again unless a hidden pane is skipped. Guard: the
|
||||
// median frame with PERF_GUARD panes stays within FRAME_RATIO_MAX of the one-pane median, a pane that
|
||||
// was hidden comes back at the scroll position it left, and the transcript does not inherit the animated
|
||||
// column widths: with a long transcript the fold costs style recalculation of every element under it.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const CPU = Number(process.env.PERF_CPU ?? 4);
|
||||
const TURNS = Number(process.env.PERF_TURNS ?? 60);
|
||||
const REPS = Number(process.env.PERF_REPS ?? 6);
|
||||
const COUNTS = (process.env.PERF_N ?? "1,3,6").split(",").map(Number);
|
||||
const GUARD = Number(process.env.PERF_GUARD ?? 3);
|
||||
const FRAME_RATIO_MAX = Number(process.env.PERF_FRAME_RATIO ?? 2.5);
|
||||
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const pct = (a, p) => [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * p))];
|
||||
const browser = await chromium.launch();
|
||||
const rows = [];
|
||||
let kept = null;
|
||||
for (const n of COUNTS) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
await page.goto(`${URL}?panes=${n}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(800);
|
||||
await page.evaluate(async (k) => {
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
for (let i = 0; i < k; i++) {
|
||||
const id = `t${i}`, args = JSON.stringify({ command: `go test ./pkg${i}/...` });
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id, name: "bash", args } });
|
||||
window.__feed({ kind: "tool_result", tool: { id, name: "bash", args, output: "ok\n".repeat(20) } });
|
||||
window.__feed({ kind: "text", text: `第 ${i} 段回答。\n\n- 要点一\n- 要点二\n\n` });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
if (i % 10 === 9) await frame();
|
||||
}
|
||||
await frame();
|
||||
}, TURNS);
|
||||
await page.waitForTimeout(800);
|
||||
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU });
|
||||
const frames = [];
|
||||
for (let r = 0; r < REPS; r++) {
|
||||
const got = await page.evaluate(async () => {
|
||||
const out = [];
|
||||
let prev = performance.now();
|
||||
const end = prev + 450;
|
||||
const before = document.querySelector(".app")?.dataset.rail;
|
||||
const mac = /Mac/.test(navigator.platform);
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "\\", ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }));
|
||||
while (performance.now() < end) {
|
||||
await new Promise((res) => requestAnimationFrame(res));
|
||||
const now = performance.now();
|
||||
out.push(now - prev);
|
||||
prev = now;
|
||||
}
|
||||
if (document.querySelector(".app")?.dataset.rail === before) throw new Error("the rail did not toggle");
|
||||
return out;
|
||||
});
|
||||
frames.push(...got);
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
rows.push({ panes: n, frames: frames.length, p50: pct(frames, 0.5).toFixed(1), p95: pct(frames, 0.95).toFixed(1), worst: Math.max(...frames).toFixed(1), over50: frames.filter((f) => f > 50).length });
|
||||
if (n === GUARD) {
|
||||
await page.evaluate(() => { document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop = 400; });
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator(".sessrow").nth(1).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator(".sessrow").nth(0).click();
|
||||
await page.waitForTimeout(500);
|
||||
kept = await page.evaluate(() => document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop);
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
const inherited = await (async () => {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${URL}?panes=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".flow");
|
||||
const seen = await page.evaluate(async () => {
|
||||
const read = () => ["--rail-w", "--nav-w", "--side-w"].map((v) => getComputedStyle(document.querySelector(".flow")).getPropertyValue(v)).join(" ");
|
||||
const app = () => getComputedStyle(document.querySelector(".app")).getPropertyValue("--rail-w");
|
||||
const out = { flow: [read()], app: [app()] };
|
||||
const mac = /Mac/.test(navigator.platform);
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "\\", ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }));
|
||||
for (const wait of [90, 400]) {
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
out.flow.push(read());
|
||||
out.app.push(app());
|
||||
}
|
||||
return out;
|
||||
});
|
||||
await ctx.close();
|
||||
return seen;
|
||||
})();
|
||||
await browser.close();
|
||||
console.table(rows);
|
||||
const one = rows.find((r) => r.panes === 1), many = rows.find((r) => r.panes === GUARD);
|
||||
const fails = [];
|
||||
if (one && many && Number(many.p50) > Number(one.p50) * FRAME_RATIO_MAX) {
|
||||
fails.push(`median frame with ${GUARD} panes is ${many.p50} ms against ${one.p50} ms with one (limit x${FRAME_RATIO_MAX})`);
|
||||
}
|
||||
if (new Set(inherited.app).size < 2) fails.push(`the rail width did not change during the fold (${inherited.app.join(" -> ")}), so the guard measured nothing`);
|
||||
else if (new Set(inherited.flow).size > 1) fails.push(`the transcript inherits the animated column widths (${inherited.flow.join(" -> ")}); every element under it is restyled on each frame of the fold`);
|
||||
if (kept !== null && Math.abs(kept - 400) > 2) fails.push(`a pane hidden at scrollTop 400 came back at ${kept}`);
|
||||
if (fails.length) {
|
||||
console.error(fails.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -59,9 +59,10 @@ for (const [ws, sess] of SHAPES) {
|
||||
const after = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
|
||||
|
||||
// 展开一个折叠的工作区,量这一下点击有多久才落地。
|
||||
const twist = page.locator(".wsnode").nth(1).locator("button.twist");
|
||||
const project = page.locator(".wsnode > .wsrow").nth(1);
|
||||
if (await project.getAttribute("aria-expanded") === "true") await project.click();
|
||||
const tExpand = Date.now();
|
||||
await twist.click();
|
||||
await project.click();
|
||||
await page.waitForTimeout(50);
|
||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const expand = Date.now() - tExpand;
|
||||
|
||||
@@ -18,10 +18,14 @@ for (const scheme of ["light", "dark"]) {
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&ws=1&sess=4&turns=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const rows = '.sessrow[role="treeitem"][data-target]';
|
||||
const rows = '.wsnode:has(> .wsrow[data-target="~/projects/workspace-0"]) .sessrow[role="treeitem"][data-target]';
|
||||
const remoteRows = '.sessrow-remote[role="treeitem"][data-target]';
|
||||
const order = () => page.evaluate((sel) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute("data-target")?.replace(/.*\/|\.jsonl/g, "")), rows);
|
||||
await page.waitForSelector(rows);
|
||||
const start = await order();
|
||||
const remoteOrder = () => page.locator(remoteRows).evaluateAll((els) => els.map((el) => el.dataset.target));
|
||||
const remoteStart = await remoteOrder();
|
||||
check(`${tag}: SSH conversations share the list`, remoteStart.length > 0, remoteStart.join());
|
||||
check(`${tag}: the project lists its four conversations`, start.length === 4, start.join());
|
||||
|
||||
const first = page.locator(rows).first();
|
||||
@@ -41,6 +45,8 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.keyboard.press("Alt+ArrowUp");
|
||||
await page.waitForFunction(([sel, s]) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute("data-target")?.replace(/.*\/|\.jsonl/g, "")).join() === s, [rows, start.join()]);
|
||||
check(`${tag}: Alt+Up on the focused row puts it back first`, (await order()).join() === start.join());
|
||||
check(`${tag}: sorting local conversations preserves the SSH rows`, JSON.stringify(await remoteOrder()) === JSON.stringify(remoteStart));
|
||||
check(`${tag}: sorting keeps focus on the moved row`, await page.locator(rows).first().evaluate((el) => el === document.activeElement));
|
||||
await page.mouse.move(width - 3, 895);
|
||||
if (shotDir) await page.locator(".rail").screenshot({ path: `${shotDir}/${scheme}-rail.png` });
|
||||
await page.close();
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const sheets = await Promise.all(["tokens", "app", "studio", "sessionquick", "sessionpin"].map(
|
||||
const sheets = await Promise.all(["tokens", "app", "studio", "sessionquick", "sessionpin", "treerows"].map(
|
||||
(name) => readFile(new URL(`../src/styles/${name}.css`, import.meta.url), "utf8"),
|
||||
));
|
||||
const title = "这是什么? @.reasonix/attachments/clipboard-" + "long-title".repeat(20);
|
||||
@@ -14,8 +14,9 @@ try {
|
||||
await page.setContent(`<div class="rail" style="width:${width}px">
|
||||
<div class="kids">
|
||||
<div class="sessrow sessrow-context" tabindex="0" aria-selected="true">
|
||||
<span class="sesstitle" title="${title}"><i class="sesspin"></i><span>${title}</span><em class="sessarch">已归档</em></span>
|
||||
<div class="sessquick"><button>归档</button><button>删除</button></div>
|
||||
<button class="sesspin-action" data-action="session.pin">置顶</button>
|
||||
<span class="sesstitle" title="${title}"><span>${title}</span><em class="sessarch">已归档</em></span>
|
||||
<span class="sessquick"><button data-action="session.archive">归档</button></span>
|
||||
</div>
|
||||
<div class="sessrow sessrow-context" data-unread tabindex="0">
|
||||
<span class="sesstitle" title="短标题"><span>短标题</span></span><span class="unread-dot"></span>
|
||||
@@ -23,6 +24,18 @@ try {
|
||||
<div class="sessrow sessrow-remote">
|
||||
<span class="sesstitle" title="${title}"><span>${title}</span></span><span class="sessmeta">3 轮</span>
|
||||
</div>
|
||||
<div class="sessrow sessrow-context" data-aged>
|
||||
<button class="sesspin-action" data-action="session.pin">置顶</button>
|
||||
<span class="sesstitle" title="${title}"><span>${title}</span></span>
|
||||
<time class="sessmeta sessage">9 天前</time><button class="sesscopies">+1</button>
|
||||
<span class="sessquick"><button data-action="session.archive">归档</button></span>
|
||||
</div>
|
||||
<div class="sessrow sessrow-context" data-aged-only>
|
||||
<button class="sesspin-action" data-action="session.pin">置顶</button>
|
||||
<span class="sesstitle" title="${title}"><span>${title}</span></span>
|
||||
<time class="sessmeta sessage">9 天前</time>
|
||||
<span class="sessquick"><button data-action="session.archive">归档</button></span>
|
||||
</div>
|
||||
<div class="sessrow sesscopy">
|
||||
<span class="sesstitle" title="早先版本 · 4 轮"><span>早先版本 · 4 轮</span></span>
|
||||
</div>
|
||||
@@ -52,12 +65,30 @@ try {
|
||||
assert.ok(row.textHeight <= row.lineHeight + 1);
|
||||
assert.ok(row.fullTitle);
|
||||
}
|
||||
assert.equal(rows[1].top, rows[0].bottom, "no gap between sessions");
|
||||
assert.equal(rows[2].top, rows[1].bottom, "no gap before remote session");
|
||||
assert.equal(rows[3].top, rows[2].bottom, "no gap before earlier version");
|
||||
assert.equal(rows[1].top - rows[0].bottom, 1, "only the compact row margin separates sessions");
|
||||
assert.equal(rows[2].top - rows[1].bottom, 1, "the remote row has the same compact spacing");
|
||||
assert.equal(rows[3].top - rows[2].bottom, 1, "the aged row has the same compact spacing");
|
||||
assert.equal(rows[4].top - rows[3].bottom, 1, "the age-only row has the same compact spacing");
|
||||
assert.equal(rows[5].top - rows[4].bottom, 1, "the earlier version has the same compact spacing");
|
||||
assert.ok(rows[0].truncated && rows[2].truncated, "long local and remote titles truncate");
|
||||
if (state === "idle") {
|
||||
for (const [selector, allowed] of [[".sessrow[data-aged]", 8], [".sessrow[data-aged-only]", 2]]) {
|
||||
const gap = await page.evaluate((sel) => {
|
||||
const row = document.querySelector(sel);
|
||||
const box = row.getBoundingClientRect();
|
||||
const inner = box.right - parseFloat(getComputedStyle(row).paddingRight);
|
||||
const shown = [...row.children].filter((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return cs.position !== "absolute" && cs.visibility !== "hidden" && el.getBoundingClientRect().width > 0 && !el.matches(".sessquick");
|
||||
});
|
||||
return { free: inner - Math.max(...shown.map((el) => el.getBoundingClientRect().right)), quick: row.querySelector(".sessquick").getBoundingClientRect().width };
|
||||
}, selector);
|
||||
assert.ok(gap.free <= allowed, `${scheme}/${width} ${selector}: ${gap.free}px of the row's right side is held back while no button is showing; the title should have it`);
|
||||
assert.equal(gap.quick, 0, `${scheme}/${width}: the hover buttons take no room until the row is hovered or focused`);
|
||||
}
|
||||
}
|
||||
if (state !== "idle") {
|
||||
const contained = await page.locator(".sessquick > button").evaluateAll((buttons) => buttons.every((button) => {
|
||||
const contained = await page.locator(".sessquick > button, .sesspin-action").evaluateAll((buttons) => buttons.filter((button) => (button.parentElement.matches(".sessquick") ? button.parentElement.getBoundingClientRect().width > 0 : getComputedStyle(button).opacity !== "0")).every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const row = button.closest(".sessrow").getBoundingClientRect();
|
||||
return rect.top >= row.top && rect.bottom <= row.bottom && rect.left >= row.left && rect.right <= row.right;
|
||||
@@ -65,7 +96,7 @@ try {
|
||||
assert.ok(contained, "quick actions fit inside the compact row");
|
||||
}
|
||||
}
|
||||
console.log(`${scheme}/${width}: 34px rows, no gaps, single-line ellipsis, hover/focus stable`);
|
||||
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Session rows carry hover Archive and Delete buttons only when this machine turned them on. The reveal is a
|
||||
// width and opacity transition on :hover, so whether the buttons are really visible and hit-testable is
|
||||
// a question for a real browser.
|
||||
// Opt-in session actions put Pin before the title and Archive after it.
|
||||
// Their visibility, geometry and pointer targets need a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
@@ -14,34 +13,56 @@ const check = (name, ok, detail = "") => {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const on of [false, true]) {
|
||||
for (const on of [null, false, true]) {
|
||||
const mode = on === null ? "default" : on ? "on" : "off";
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 800 } });
|
||||
if (on) await ctx.addInitScript(() => localStorage.setItem("rx-session-quick-actions", "on"));
|
||||
if (on !== null) await ctx.addInitScript((enabled) => localStorage.setItem("rx-session-quick-actions", enabled ? "on" : "off"), on);
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push(e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".sessrow", { timeout: 20000 });
|
||||
const row = page.locator(".sessrow").first();
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.move(box.x + 40, box.y + box.height / 2);
|
||||
await page.waitForTimeout(300);
|
||||
const quick = page.locator(".sessrow").first().locator(".sessquick");
|
||||
if (!on) {
|
||||
check(`${scheme}: off draws no quick buttons on a hovered row`, (await quick.count()) === 0);
|
||||
} else {
|
||||
const state = await quick.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const [archive, trash] = el.querySelectorAll("button");
|
||||
const hit = (b) => {
|
||||
const q = b.getBoundingClientRect();
|
||||
const at = document.elementFromPoint(q.left + q.width / 2, q.top + q.height / 2);
|
||||
return at === b || b.contains(at);
|
||||
};
|
||||
return { opacity: Number(getComputedStyle(el).opacity), width: r.width, archive: hit(archive), trash: hit(trash) };
|
||||
});
|
||||
check(`${scheme}: on reveals both buttons on a hovered row`, state.opacity === 1 && state.width > 40 && state.archive && state.trash, JSON.stringify(state));
|
||||
for (const kind of ["context", "remote"]) {
|
||||
const tag = `${scheme}/${mode}/${kind}`;
|
||||
const row = page.locator(`.sessrow-${kind}`).first();
|
||||
await row.hover();
|
||||
await page.waitForTimeout(150);
|
||||
const quick = row.locator('[data-action="session.pin"], [data-action="session.archive"]');
|
||||
if (!on) {
|
||||
check(`${tag}: hover draws no quick buttons`, (await quick.count()) === 0);
|
||||
} else {
|
||||
const state = await row.evaluate((el) => {
|
||||
const title = el.querySelector(".sesstitle > span").getBoundingClientRect();
|
||||
const pin = el.querySelector('[data-action="session.pin"]');
|
||||
const archive = el.querySelector('[data-action="session.archive"]');
|
||||
const hit = (button) => {
|
||||
if (!button) return false;
|
||||
const r = button.getBoundingClientRect();
|
||||
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
let opacity = 1;
|
||||
for (let node = button; node && node !== el; node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity);
|
||||
return opacity === 1 && r.width > 0 && r.height > 0 && !button.disabled && (at === button || button.contains(at));
|
||||
};
|
||||
return { pin: hit(pin), archive: hit(archive), leading: pin.getBoundingClientRect().right <= title.left,
|
||||
trailing: archive.getBoundingClientRect().left >= title.right - 1 };
|
||||
});
|
||||
check(`${tag}: leading Pin and trailing Archive are hit-testable`, state.pin && state.archive && state.leading && state.trailing, JSON.stringify(state));
|
||||
await page.mouse.move(1398, 798);
|
||||
await row.locator('[data-action="session.pin"]').focus();
|
||||
await page.waitForTimeout(150);
|
||||
check(`${tag}: keyboard focus reveals both actions`, await quick.evaluateAll((buttons) => buttons.every((b) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
let opacity = 1;
|
||||
for (let node = b; node && !node.classList.contains("sessrow"); node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity);
|
||||
return opacity === 1 && (b === hit || b.contains(hit));
|
||||
})));
|
||||
}
|
||||
await row.click({ button: "right" });
|
||||
check(`${tag}: the menu retains Pin and Archive`, await page.getByRole("menuitem", { name: "置顶会话", exact: true }).count() === 1 && await page.getByRole("menuitem", { name: "归档会话", exact: true }).count() === 1);
|
||||
check(`${tag}: Delete stays in the menu`, await page.getByRole("menuitem", { name: "删除会话", exact: true }).count() === 1);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/sessquick-${on ? "on" : "off"}-${scheme}.png`, clip: { x: 0, y: 0, width: 420, height: 800 } });
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/sessquick-${mode}-${scheme}.png`, clip: { x: 0, y: 0, width: 420, height: 800 } });
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// A `!` line refused because this connection cannot run commands comes back selected, so the next message replaces it and is sent.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shotDir = process.env.SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280, 420]) {
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&turns=2&netshell=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const box = page.locator('[aria-label="任务输入"]');
|
||||
await box.waitFor();
|
||||
await box.fill("!ls -la");
|
||||
await box.press("Enter");
|
||||
await page.waitForFunction(() => (window.__sent ?? []).length === 1);
|
||||
await page.waitForFunction(() => { const t = document.querySelector('[aria-label="任务输入"]'); return t && t.value === "!ls -la" && t.selectionStart === 0 && t.selectionEnd === 7; });
|
||||
check(`${tag}: the refused command is back in the box, all of it selected`, true);
|
||||
const said = await page.evaluate(() => document.body.textContent.includes("HTTP 上不提供 shell 命令"));
|
||||
check(`${tag}: the refusal is worded`, said);
|
||||
if (shotDir) { await page.mouse.move(width - 3, 895); await page.waitForTimeout(300); await page.screenshot({ path: `${shotDir}/${scheme}-${width}-refused.png` }); }
|
||||
await page.keyboard.type("你好");
|
||||
check(`${tag}: typing replaces the command instead of joining it`, (await box.inputValue()) === "你好");
|
||||
await box.press("Enter");
|
||||
await page.waitForFunction(() => (window.__sent ?? []).length === 2);
|
||||
const sent = await page.evaluate(() => window.__sent);
|
||||
check(`${tag}: the next ordinary message is sent`, sent[1] === "你好", JSON.stringify(sent));
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) { console.log(`\n${fails.length} failed`); process.exit(1); }
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,34 @@
|
||||
// A conversation out of sight that is asking whether to trust its folder says so in the
|
||||
// sidebar; the one in front shows the card itself and carries no such mark.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 800 }, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&panes=2&turns=1&trustpending=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.waitForTimeout(800);
|
||||
const rows = await page.evaluate(() => [...document.querySelectorAll(".sessrow[data-run]")].map((r) => r.getAttribute("data-run")));
|
||||
const waiting = rows.filter((r) => r === "halt").length;
|
||||
check(`${scheme}: exactly one conversation, the one out of sight, is marked as waiting`, waiting === 1, JSON.stringify(rows));
|
||||
const cards = await page.evaluate(() => document.querySelectorAll('.posturenote[data-kind="trust"]').length);
|
||||
check(`${scheme}: each conversation still carries its own trust card`, cards === 2, String(cards));
|
||||
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-trust-wait.png`, clip: { x: 0, y: 0, width: 700, height: 460 } });
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -276,17 +276,17 @@ await page.goto(`${PAGE}?ws=6&sess=200`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".wsnode", { timeout: 20000 });
|
||||
await page.waitForTimeout(600);
|
||||
const firstWs = page.locator(".wsnode").first();
|
||||
check("大树只列最近的", (await firstWs.locator(".sessrow").count()) === 30, `${await firstWs.locator(".sessrow").count()} 行`);
|
||||
check("大树只列最近的", (await firstWs.locator(".sessrow").count()) === 5, `${await firstWs.locator(".sessrow").count()} 行`);
|
||||
check("有「全部显示」", await firstWs.locator("button.sessmore").isVisible());
|
||||
await firstWs.locator("button.sessmore").click();
|
||||
await page.waitForTimeout(400);
|
||||
check("展开后列全", (await firstWs.locator(".sessrow").count()) === 200, `${await firstWs.locator(".sessrow").count()} 行`);
|
||||
await firstWs.locator("button.twist").click();
|
||||
await firstWs.locator(":scope > .wsrow").click();
|
||||
await page.waitForTimeout(300);
|
||||
check("折叠后收起", (await firstWs.locator(".sessrow").count()) === 0);
|
||||
await page.screenshot({ path: `${SHOTS}/6-大会话树.png` });
|
||||
// Only the workspace in front opens on first load; the next one is unfolded by hand.
|
||||
await page.locator(".wsnode").nth(1).locator(".wsrow").click();
|
||||
const nextProject = page.locator(".wsnode > .wsrow").nth(1);
|
||||
if (await nextProject.getAttribute("aria-expanded") !== "true") await nextProject.click();
|
||||
await page.waitForTimeout(300);
|
||||
const second = page.locator(".wsnode").nth(1).locator(".sessrow").first();
|
||||
await second.click();
|
||||
|
||||
Generated
+192
-219
@@ -10,7 +10,7 @@ importers:
|
||||
dependencies:
|
||||
'@a2ui/react':
|
||||
specifier: 0.13.0
|
||||
version: 0.13.0([email protected].0([email protected].0))([email protected].0)([email protected])
|
||||
version: 0.13.0([email protected].1([email protected].1))([email protected].1)([email protected])
|
||||
'@a2ui/web_core':
|
||||
specifier: 0.13.0
|
||||
version: 0.13.0
|
||||
@@ -70,13 +70,13 @@ importers:
|
||||
version: 1.2.5
|
||||
'@mantine/charts':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2(@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0))(@mantine/[email protected]([email protected].0))([email protected].0([email protected].0))([email protected].0)([email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]))
|
||||
version: 9.7.2(@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1))(@mantine/[email protected]([email protected].1))([email protected].1([email protected].1))([email protected].1)([email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]))
|
||||
'@mantine/core':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2(@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
version: 9.7.2(@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
'@mantine/hooks':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2([email protected].0)
|
||||
version: 9.7.2([email protected].1)
|
||||
'@noble/curves':
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0
|
||||
@@ -90,20 +90,20 @@ importers:
|
||||
specifier: 6.14.0
|
||||
version: 6.14.0
|
||||
react:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
specifier: ^19.3.1
|
||||
version: 19.3.1
|
||||
react-dom:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0([email protected].0)
|
||||
specifier: ^19.3.1
|
||||
version: 19.3.1([email protected].1)
|
||||
react-map-gl:
|
||||
specifier: 8.1.3
|
||||
version: 8.1.3([email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
version: 8.1.3([email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/[email protected])([email protected].0)
|
||||
version: 10.1.0(@types/[email protected])([email protected].1)
|
||||
recharts:
|
||||
specifier: 3.10.1
|
||||
version: 3.10.1(@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected])
|
||||
version: 3.10.1(@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected])
|
||||
rehype-highlight:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
@@ -134,10 +134,10 @@ importers:
|
||||
version: 8.0.7
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.3
|
||||
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.7
|
||||
version: 14.6.7(@testing-library/[email protected])
|
||||
specifier: ^14.6.8
|
||||
version: 14.6.8(@testing-library/[email protected])
|
||||
'@types/react':
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
@@ -146,22 +146,22 @@ importers:
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2([email protected].3([email protected]))
|
||||
version: 6.1.2([email protected].4([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.2
|
||||
version: 30.1.2(@noble/[email protected])
|
||||
specifier: ^30.1.3
|
||||
version: 30.1.3(@noble/[email protected])
|
||||
playwright:
|
||||
specifier: ^1.64.0
|
||||
version: 1.64.0
|
||||
specifier: ^1.64.1
|
||||
version: 1.64.1
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.3
|
||||
version: 8.3.3([email protected])
|
||||
specifier: ^8.3.4
|
||||
version: 8.3.4([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3([email protected].2(@noble/[email protected]))([email protected].3([email protected]))
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4([email protected].3(@noble/[email protected]))([email protected].4([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -185,8 +185,8 @@ packages:
|
||||
resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
'@asamuzakjp/dom-selector@9.2.4':
|
||||
resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==}
|
||||
'@asamuzakjp/dom-selector@10.0.3':
|
||||
resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
'@babel/[email protected]':
|
||||
@@ -474,15 +474,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@exodus/[email protected]':
|
||||
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@exodus/[email protected]':
|
||||
resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
@@ -783,8 +774,8 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@testing-library/[email protected].7':
|
||||
resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==}
|
||||
'@testing-library/[email protected].8':
|
||||
resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
@@ -1028,10 +1019,10 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].3':
|
||||
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
|
||||
'@vitest/[email protected].4':
|
||||
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
msw: ^2.4.9 || ^3.0.0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
@@ -1039,8 +1030,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].3':
|
||||
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
|
||||
'@vitest/[email protected].4':
|
||||
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
@@ -1083,8 +1074,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
chai@6.3.0:
|
||||
resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1396,8 +1387,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.2.3
|
||||
@@ -1527,8 +1518,8 @@ packages:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
|
||||
@@ -1691,13 +1682,13 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
|
||||
|
||||
[email protected].18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
[email protected].20:
|
||||
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
obug@2.1.4:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
obug@3.0.0:
|
||||
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1720,18 +1711,18 @@ packages:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected].28:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
[email protected].30:
|
||||
resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
@@ -1758,10 +1749,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
|
||||
peerDependencies:
|
||||
react: ^19.3.0
|
||||
react: ^19.3.1
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
@@ -1833,8 +1824,8 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1937,6 +1928,10 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
@@ -1944,8 +1939,8 @@ packages:
|
||||
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
std-env@4.3.0:
|
||||
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
@@ -1969,12 +1964,12 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinybench@6.1.4:
|
||||
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
|
||||
tinybench@6.2.2:
|
||||
resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1995,10 +1990,6 @@ packages:
|
||||
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
@@ -2030,8 +2021,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
[email protected]1.2:
|
||||
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
|
||||
[email protected]2.1:
|
||||
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -2102,8 +2093,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2145,20 +2136,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
|
||||
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.3
|
||||
'@vitest/browser-preview': 5.0.3
|
||||
'@vitest/browser-playwright': 5.0.4
|
||||
'@vitest/browser-preview': 5.0.4
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.3
|
||||
'@vitest/coverage-v8': 5.0.3
|
||||
'@vitest/ui': 5.0.3
|
||||
'@vitest/coverage-istanbul': 5.0.4
|
||||
'@vitest/coverage-v8': 5.0.4
|
||||
'@vitest/ui': 5.0.4
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -2204,10 +2195,6 @@ packages:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
@@ -2243,14 +2230,14 @@ snapshots:
|
||||
dompurify: 3.4.11
|
||||
markdown-it: 14.3.2
|
||||
|
||||
'@a2ui/[email protected]([email protected].0([email protected].0))([email protected].0)([email protected])':
|
||||
'@a2ui/[email protected]([email protected].1([email protected].1))([email protected].1)([email protected])':
|
||||
dependencies:
|
||||
'@a2ui/markdown-it': 0.2.1(@a2ui/[email protected])
|
||||
'@a2ui/web_core': 0.13.0
|
||||
clsx: 2.1.1
|
||||
markdown-it: 14.3.2
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
zod: 3.25.76
|
||||
|
||||
'@a2ui/[email protected]':
|
||||
@@ -2271,7 +2258,7 @@ snapshots:
|
||||
'@csstools/css-tokenizer': 4.0.2
|
||||
lru-cache: 11.5.3
|
||||
|
||||
'@asamuzakjp/dom-selector@9.2.4':
|
||||
'@asamuzakjp/dom-selector@10.0.3':
|
||||
dependencies:
|
||||
bidi-js: 1.1.0
|
||||
css-tree: 3.2.1
|
||||
@@ -2555,10 +2542,6 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@exodus/[email protected](@noble/[email protected])':
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 2.4.0
|
||||
|
||||
'@exodus/[email protected](@noble/[email protected])':
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 2.4.0
|
||||
@@ -2572,18 +2555,18 @@ snapshots:
|
||||
'@floating-ui/core': 1.8.0
|
||||
'@floating-ui/utils': 0.2.12
|
||||
|
||||
'@floating-ui/[email protected]([email protected].0([email protected].0))([email protected].0)':
|
||||
'@floating-ui/[email protected]([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.8.0
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
|
||||
'@floating-ui/[email protected]([email protected].0([email protected].0))([email protected].0)':
|
||||
'@floating-ui/[email protected]([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.9([email protected].0([email protected].0))([email protected].0)
|
||||
'@floating-ui/react-dom': 2.1.9([email protected].1([email protected].1))([email protected].1)
|
||||
'@floating-ui/utils': 0.2.12
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
tabbable: 6.5.0
|
||||
|
||||
'@floating-ui/[email protected]': {}
|
||||
@@ -2682,30 +2665,30 @@ snapshots:
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.6.0
|
||||
|
||||
'@mantine/[email protected](@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0))(@mantine/[email protected]([email protected].0))([email protected].0([email protected].0))([email protected].0)([email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]))':
|
||||
'@mantine/[email protected](@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1))(@mantine/[email protected]([email protected].1))([email protected].1([email protected].1))([email protected].1)([email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]))':
|
||||
dependencies:
|
||||
'@mantine/core': 9.7.2(@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
'@mantine/hooks': 9.7.2([email protected].0)
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
recharts: 3.10.1(@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected])
|
||||
'@mantine/core': 9.7.2(@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
'@mantine/hooks': 9.7.2([email protected].1)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
recharts: 3.10.1(@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected])
|
||||
|
||||
'@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)':
|
||||
'@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20([email protected].0([email protected].0))([email protected].0)
|
||||
'@mantine/hooks': 9.7.2([email protected].0)
|
||||
'@floating-ui/react': 0.27.20([email protected].1([email protected].1))([email protected].1)
|
||||
'@mantine/hooks': 9.7.2([email protected].1)
|
||||
clsx: 2.1.1
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react-number-format: 5.4.5([email protected].0([email protected].0))([email protected].0)
|
||||
react-remove-scroll: 2.7.2(@types/[email protected])([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
react-number-format: 5.4.5([email protected].1([email protected].1))([email protected].1)
|
||||
react-remove-scroll: 2.7.2(@types/[email protected])([email protected].1)
|
||||
type-fest: 5.10.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/[email protected]([email protected].0)':
|
||||
'@mantine/[email protected]([email protected].1)':
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
|
||||
'@mapbox/[email protected]': {}
|
||||
|
||||
@@ -2767,7 +2750,7 @@ snapshots:
|
||||
|
||||
'@preact/[email protected]': {}
|
||||
|
||||
'@reduxjs/[email protected]([email protected](@types/[email protected])([email protected].0)([email protected]))([email protected].0)':
|
||||
'@reduxjs/[email protected]([email protected](@types/[email protected])([email protected].1)([email protected]))([email protected].1)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
@@ -2776,8 +2759,8 @@ snapshots:
|
||||
redux-thunk: 3.1.0([email protected])
|
||||
reselect: 5.2.0
|
||||
optionalDependencies:
|
||||
react: 19.3.0
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected].0)([email protected])
|
||||
react: 19.3.1
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected].1)([email protected])
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
optional: true
|
||||
@@ -2841,17 +2824,17 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 27.5.1
|
||||
|
||||
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)':
|
||||
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@testing-library/dom': 10.4.1
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
'@types/react-dom': 19.3.0(@types/[email protected])
|
||||
|
||||
'@testing-library/[email protected].7(@testing-library/[email protected])':
|
||||
'@testing-library/[email protected].8(@testing-library/[email protected])':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
|
||||
@@ -2990,34 +2973,34 @@ snapshots:
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
|
||||
'@vis.gl/[email protected]([email protected].0([email protected].0))([email protected].0)':
|
||||
'@vis.gl/[email protected]([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
|
||||
'@vis.gl/[email protected]([email protected])([email protected].0([email protected].0))([email protected].0)':
|
||||
'@vis.gl/[email protected]([email protected])([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@maplibre/maplibre-gl-style-spec': 19.3.3
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
|
||||
'@vitejs/[email protected]([email protected].3([email protected]))':
|
||||
'@vitejs/[email protected]([email protected].4([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.3([email protected])
|
||||
vite: 8.3.4([email protected])
|
||||
|
||||
'@vitest/[email protected].3([email protected].3([email protected]))':
|
||||
'@vitest/[email protected].4([email protected].4([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.3
|
||||
'@vitest/spy': 5.0.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.2
|
||||
magic-string: 1.4.3
|
||||
optionalDependencies:
|
||||
vite: 8.3.3([email protected])
|
||||
vite: 8.3.4([email protected])
|
||||
|
||||
'@vitest/[email protected].3': {}
|
||||
'@vitest/[email protected].4': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3052,7 +3035,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
chai@6.3.0: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3130,7 +3113,7 @@ snapshots:
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 17.1.2(@noble/[email protected])
|
||||
whatwg-url: 17.2.0(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
@@ -3358,7 +3341,7 @@ snapshots:
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||
'@exodus/bytes': 1.16.0(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
@@ -3401,10 +3384,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].2(@noble/[email protected]):
|
||||
[email protected].3(@noble/[email protected]):
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 7.1.3
|
||||
'@asamuzakjp/dom-selector': 9.2.4
|
||||
'@asamuzakjp/dom-selector': 10.0.3
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.15([email protected])
|
||||
'@exodus/bytes': 1.16.0(@noble/[email protected])
|
||||
@@ -3417,7 +3400,7 @@ snapshots:
|
||||
parse5: 8.0.1
|
||||
saxes: 6.0.0
|
||||
tough-cookie: 6.0.2
|
||||
undici: 8.11.2
|
||||
undici: 8.12.1
|
||||
w3c-xmlserializer: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
@@ -3523,7 +3506,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].2:
|
||||
[email protected].3:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
@@ -3935,9 +3918,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].18: {}
|
||||
[email protected].20: {}
|
||||
|
||||
obug@2.1.4: {}
|
||||
obug@3.0.0: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -3965,17 +3948,17 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected].0:
|
||||
[email protected].1:
|
||||
dependencies:
|
||||
playwright-core: 1.64.0
|
||||
playwright-core: 1.64.1
|
||||
|
||||
[email protected].28:
|
||||
[email protected].30:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
nanoid: 3.3.20
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3995,23 +3978,23 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].0([email protected].0):
|
||||
[email protected].1([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
scheduler: 0.28.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected])([email protected].0([email protected].0))([email protected].0):
|
||||
[email protected]([email protected])([email protected].1([email protected].1))([email protected].1):
|
||||
dependencies:
|
||||
'@vis.gl/react-mapbox': 8.1.3([email protected].0([email protected].0))([email protected].0)
|
||||
'@vis.gl/react-maplibre': 8.1.3([email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
'@vis.gl/react-mapbox': 8.1.3([email protected].1([email protected].1))([email protected].1)
|
||||
'@vis.gl/react-maplibre': 8.1.3([email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -4020,7 +4003,7 @@ snapshots:
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
html-url-attributes: 3.0.1
|
||||
mdast-util-to-hast: 13.2.1
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
remark-parse: 11.0.0
|
||||
remark-rehype: 11.1.2
|
||||
unified: 11.0.5
|
||||
@@ -4029,64 +4012,64 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]([email protected].0([email protected].0))([email protected].0):
|
||||
[email protected]([email protected].1([email protected].1))([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0)([email protected]):
|
||||
[email protected](@types/[email protected])([email protected].1)([email protected]):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.3.0
|
||||
use-sync-external-store: 1.7.0([email protected].0)
|
||||
react: 19.3.1
|
||||
use-sync-external-store: 1.7.0([email protected].1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
redux: 5.0.1
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-style-singleton: 2.2.3(@types/[email protected])([email protected].0)
|
||||
react: 19.3.1
|
||||
react-style-singleton: 2.2.3(@types/[email protected])([email protected].1)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-remove-scroll-bar: 2.3.8(@types/[email protected])([email protected].0)
|
||||
react-style-singleton: 2.2.3(@types/[email protected])([email protected].0)
|
||||
react: 19.3.1
|
||||
react-remove-scroll-bar: 2.3.8(@types/[email protected])([email protected].1)
|
||||
react-style-singleton: 2.2.3(@types/[email protected])([email protected].1)
|
||||
tslib: 2.8.1
|
||||
use-callback-ref: 1.3.3(@types/[email protected])([email protected].0)
|
||||
use-sidecar: 1.1.3(@types/[email protected])([email protected].0)
|
||||
use-callback-ref: 1.3.3(@types/[email protected])([email protected].1)
|
||||
use-sidecar: 1.1.3(@types/[email protected])([email protected].1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]):
|
||||
[email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.13.0([email protected](@types/[email protected])([email protected].0)([email protected]))([email protected].0)
|
||||
'@reduxjs/toolkit': 2.13.0([email protected](@types/[email protected])([email protected].1)([email protected]))([email protected].1)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.52.0
|
||||
eventemitter3: 5.0.4
|
||||
immer: 11.1.21
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
react-is: 17.0.2
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected].0)([email protected])
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected].1)([email protected])
|
||||
reselect: 5.2.0
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.7.0([email protected].0)
|
||||
use-sync-external-store: 1.7.0([email protected].1)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
@@ -4243,13 +4226,15 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
extend-shallow: 3.0.2
|
||||
|
||||
std-env@4.2.0: {}
|
||||
std-env@4.3.0: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -4272,9 +4257,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
tinybench@6.1.4: {}
|
||||
tinybench@6.2.2: {}
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -4293,10 +4278,6 @@ snapshots:
|
||||
dependencies:
|
||||
tldts: 7.4.11
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -4342,7 +4323,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]1.2: {}
|
||||
[email protected]2.1: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -4394,24 +4375,24 @@ snapshots:
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
detect-node-es: 1.1.0
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected]([email protected].0):
|
||||
[email protected]([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -4447,35 +4428,35 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
[email protected].3([email protected]):
|
||||
[email protected].4([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
postcss: 8.5.30
|
||||
rolldown: 1.2.12
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected].3([email protected].2(@noble/[email protected]))([email protected].3([email protected])):
|
||||
[email protected].4([email protected].3(@noble/[email protected]))([email protected].4([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.3([email protected].3([email protected]))
|
||||
chai: 6.2.2
|
||||
'@vitest/mocker': 5.0.4([email protected].4([email protected]))
|
||||
chai: 6.3.0
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
magic-string: 1.4.2
|
||||
obug: 2.1.4
|
||||
magic-string: 1.4.3
|
||||
obug: 3.0.0
|
||||
picomatch: 4.0.7
|
||||
std-env: 4.2.0
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
std-env: 4.3.0
|
||||
tinybench: 6.2.2
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.3([email protected])
|
||||
vite: 8.3.4([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
jsdom: 30.1.2(@noble/[email protected])
|
||||
jsdom: 30.1.3(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -4491,14 +4472,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.16.0(@noble/[email protected])
|
||||
|
||||
@@ -10,3 +10,6 @@
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/push-sw.js
|
||||
Cache-Control: no-store
|
||||
@@ -0,0 +1,50 @@
|
||||
const TEXT = {
|
||||
zh: {
|
||||
approval: ["有操作等你批准", "电脑上的 Reasonix 正停在那里等你。"],
|
||||
input: ["模型在等你回答", "电脑上的 Reasonix 需要你的回答才能继续。"],
|
||||
error: ["一轮因错误结束", "电脑上的 Reasonix 有一轮没有跑完。"],
|
||||
other: ["Reasonix", "电脑上有事等你。"],
|
||||
},
|
||||
en: {
|
||||
approval: ["An action needs your approval", "Reasonix on your computer is waiting for you."],
|
||||
input: ["The model is waiting for your answer", "Reasonix on your computer needs an answer to go on."],
|
||||
error: ["A turn ended with an error", "A turn on your computer did not finish."],
|
||||
other: ["Reasonix", "Something on your computer is waiting for you."],
|
||||
},
|
||||
};
|
||||
|
||||
function describe(data) {
|
||||
const language = /^zh/i.test(self.navigator.language || "") ? "zh" : "en";
|
||||
const known = data && data.v === 1 && Object.hasOwn(TEXT.en, data.type) && data.type !== "other";
|
||||
const type = known ? data.type : "other";
|
||||
const count = known && Number.isInteger(data.count) && data.count > 1 && data.count < 100 ? data.count : 1;
|
||||
const [title, body] = TEXT[language][type];
|
||||
return { type, title: count > 1 ? `${title} (${count})` : title, body };
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = null;
|
||||
try {
|
||||
data = event.data ? event.data.json() : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
const shown = describe(data);
|
||||
event.waitUntil(self.registration.showNotification(shown.title, {
|
||||
body: shown.body,
|
||||
tag: shown.type,
|
||||
renotify: true,
|
||||
icon: "icons/icon-192.png",
|
||||
badge: "icons/icon-192.png",
|
||||
}));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil((async () => {
|
||||
const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
const page = open.find((client) => client.url.startsWith(self.registration.scope));
|
||||
if (page) await page.focus();
|
||||
else await self.clients.openWindow(self.registration.scope);
|
||||
})());
|
||||
});
|
||||
@@ -1,15 +1,5 @@
|
||||
// What Studio claims a person can do, as a set of stable identities.
|
||||
//
|
||||
// The oracle for the census, and deliberately not something production imports:
|
||||
// a button writes the id as a literal and this file writes it again, so the two
|
||||
// can disagree and be caught. Sharing one constant would make the check compare
|
||||
// a value with itself — the shape of false green this tree keeps removing.
|
||||
//
|
||||
// An id is <surface>.<intent>: the semantic class, never the instance and never
|
||||
// the wording. Which entry a click is about rides on data-target, and which of
|
||||
// several answers it gives rides on data-value, so a row rendered seventy times
|
||||
// is still one contract, and 「取消」 — seventeen buttons, eight destinations —
|
||||
// is never an identity.
|
||||
// The census oracle is independent of production literals so disagreements fail.
|
||||
// An id names the intent; data-target and data-value name its entity and choice.
|
||||
|
||||
export type ActionKind =
|
||||
/** Changes only what is on screen. Must not reach the kernel. */
|
||||
@@ -48,6 +38,39 @@ export interface UIAction {
|
||||
}
|
||||
|
||||
export const ACTIONS: UIAction[] = [
|
||||
{id:"agent-composition.select",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
|
||||
{id:"agent-composition.manage",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.show-picker",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-composition.unavailable",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-composition.new",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
|
||||
{id:"agent-preset.reload",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.view",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.new-task-default",kind:"view",target:"entity",proof:"interaction"},
|
||||
{id:"agent-preset.usage",kind:"view",target:"entity",proof:"interaction"},
|
||||
{id:"agent-preset.create",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.leave",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.leave-cancel",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.leave-discard",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.persona",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.copy",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.scope",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.id",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.name",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.description",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.module-mode",kind:"view",target:"entity",proof:"interaction"},
|
||||
{id:"agent-preset.module-id",kind:"view",target:"entity",proof:"interaction"},
|
||||
{id:"agent-preset.module-parameters",kind:"view",target:"entity",proof:"interaction"},
|
||||
{id:"agent-preset.module-add",kind:"view",target:"entity",proof:"interaction"},
|
||||
{id:"agent-preset.module-choose",kind:"view",target:"entity",proof:"interaction"},
|
||||
{id:"agent-preset.module-remove",kind:"view",target:"entity",proof:"interaction"},
|
||||
{id:"agent-preset.save",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
|
||||
{id:"agent-preset.default",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
|
||||
{id:"agent-preset.workspace-default",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
|
||||
{id:"agent-preset.delete-preview",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.delete",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
|
||||
{id:"agent-preset.delete-cancel",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.export",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.import",kind:"view",target:"none",proof:"interaction"},
|
||||
// ── The window's own switches, and the way into the turn's policy ────────
|
||||
// Completion verification is adaptive now, rather than a user-facing preset.
|
||||
// The legacy endpoint remains in the port for older clients and sessions,
|
||||
@@ -55,6 +78,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chart.scroll", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "phone-device.enabled", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "phone-device.occasion", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "phone-device.retry", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "phone-device.restart", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "chrome.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.open-project", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
@@ -95,6 +122,8 @@ export const ACTIONS: UIAction[] = [
|
||||
// The way out of a window whose assets an update replaced underneath it.
|
||||
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.changed-files", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.feedback-level-notices", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "level-up.notice-dismiss", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.fold-work", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.turn-narration", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "changes.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -135,6 +164,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "image.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "image.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "tool.show-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "tool.show-full", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "agent.open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "agent.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "agent.close", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -271,9 +301,17 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
@@ -430,9 +468,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "remote.hide", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "remote.enable", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
|
||||
{ id: "remote.group", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.fold-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "root-missing.reselect", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.move-to-start", kind: "view", target: "entity", proof: "interaction" },
|
||||
@@ -456,6 +495,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "extensions.submit", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "provider.probe", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "provider.draft", kind: "interaction", target: "none", proof: "interaction" },
|
||||
{ id: "provider.price-source", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.discard-edits", kind: "destructive", target: "none", proof: "interaction" },
|
||||
{ id: "provider.revert", kind: "interaction", target: "none", proof: "interaction" },
|
||||
{ id: "provider.model-check-all", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
@@ -536,6 +576,15 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "notify.feedback-reply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.disk-low", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "phone-notify.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "phone-notify.approval", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "phone-notify.input", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "phone-notify.error", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "phone.add", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "phone.replace", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "phone.reject", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "phone.revoke", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "phone.revoke-ask", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workspace.editor", kind: "repeatable", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -640,6 +689,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "posture-note.dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "storage.move", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "session.import-legacy", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "session.import-native", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "network.diagnose", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
|
||||
// ── Permissions, the sandbox, and the endpoints a session can reach.
|
||||
@@ -674,9 +724,23 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "subagent-steer.max-bytes", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "subagent-steer.per-minute", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "write-lease.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "write-lease.max-wait", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workbench.stage-folder", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "workbench.change-folder", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.discard", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.discard-confirm", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "workbench.discard-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.repo", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.repos-reload", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.commit", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.commit-file", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "push.preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "push.confirm", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "push.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "permissions.ask-on-external-origin", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "plan-approval.auto", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "default-mode.plan", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "tool-list.toggle", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.target", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "peers.text", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "peers.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -686,6 +750,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "peers.enable", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.dispatch", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.release-revoke", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "peers.release-add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.release-pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "peers.deny", 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.
|
||||
@@ -697,8 +763,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "persona.prefix", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "persona.suffix", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "persona.preset", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "persona.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" },
|
||||
// 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" },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EN_SURFACES } from "./en_surfaces";
|
||||
import { EN_COMPOSITION } from "./en_composition";
|
||||
// English catalogue, keyed by the Chinese source text. A key that is missing
|
||||
// here renders as Chinese rather than as a blank, so an incomplete translation
|
||||
// degrades instead of breaking — and i18n.test.ts is what stops it from
|
||||
@@ -23,6 +24,7 @@ import { EN_COMMUNITY } from "./en_community";
|
||||
import { EN_SPONSOR } from "./en_sponsor";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
...EN_COMPOSITION,
|
||||
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
|
||||
...EN_SURFACES,
|
||||
"重命名聊天": "Rename chat",
|
||||
@@ -125,7 +127,50 @@ export const EN: Record<string, string> = {
|
||||
"思考 {chars}": "Thought · {chars}",
|
||||
"耗时 {t}": "Worked for {t}",
|
||||
"提交历史": "Commit history",
|
||||
"这个提交没有改动文件": "This commit changed no files",
|
||||
"没有可显示的文本改动": "There is no text change to show",
|
||||
"只显示前面的文件": "Only the first files are shown",
|
||||
"推送…": "Push…",
|
||||
"推送": "Push",
|
||||
"读取要推送的内容…": "Reading what would be pushed…",
|
||||
"已把 {branch} 推送到 {host},远端新建了这个分支": "Pushed {branch} to {host}; the branch is new on the remote",
|
||||
"已把 {n} 个提交推送到 {host}": "Pushed {n} commits to {host}",
|
||||
"推送到 {host}": "Push to {host}",
|
||||
"远端": "Remote",
|
||||
"将发送": "Will send",
|
||||
"{n} 个提交": "{n} commits",
|
||||
"远端比这个分支多 {n} 个提交(以上次获取为准),推送可能被拒绝。": "The remote has {n} commits this branch does not (as of the last fetch); the push may be rejected.",
|
||||
"远端已经有这个分支的全部提交,没有可推送的内容。": "The remote already has every commit of this branch; there is nothing to push.",
|
||||
"只推送这一个分支,从不强制推送;仓库里的钩子(包括 pre-push)不会运行。": "Pushes this one branch and never forces it; the repository's hooks (pre-push included) do not run.",
|
||||
"正在推送…": "Pushing…",
|
||||
"丢弃 {path} 的改动": "Discard the change to {path}",
|
||||
"丢弃这个文件的改动": "Discard this file's change",
|
||||
"确认丢弃": "Confirm discard",
|
||||
"丢弃 {path} 的改动?这个文件会回到上次提交的样子,改动无法恢复。": "Discard the change to {path}? The file goes back to how the last commit has it, and the change cannot be recovered.",
|
||||
"丢弃": "Discard",
|
||||
"它已暂存的部分和还没暂存的部分都会丢弃。": "Both its staged part and its unstaged part are discarded.",
|
||||
"无法读取这个仓库的提交历史": "Could not read this repository's commit history",
|
||||
"这不是一个提交编号": "That is not a commit id",
|
||||
"这个仓库里没有这个提交": "This repository has no such commit",
|
||||
"无法读取这个提交": "Could not read this commit",
|
||||
"丢弃请求的格式不对": "The discard request is malformed",
|
||||
"对话正在运行,它可能正在写这个文件,稍后再丢弃": "A turn is running and may be writing this file; discard it later",
|
||||
"这个文件没有未提交的改动": "This file has no uncommitted change",
|
||||
"只有上次提交里已有的文件,才能丢弃它的改动": "Only a change to a file the last commit already holds can be discarded",
|
||||
"丢弃失败了": "The discard failed",
|
||||
"推送请求的格式不对": "The push request is malformed",
|
||||
"这个工作区还没有被你信任,不能从这里推送": "You have not trusted this workspace, so nothing can be pushed from it",
|
||||
"现在不在任何分支上,没有可以推送的分支": "HEAD is not on a branch, so there is no branch to push",
|
||||
"这个分支还没有对应的远端分支,不能推送": "This branch has no upstream to push to",
|
||||
"远端已经有这个分支的全部提交,没有可推送的内容": "The upstream already has every commit; there is nothing to push",
|
||||
"这个远端用的传输方式不允许推送,只支持 https、ssh 和 git://": "This remote's transport is not allowed for a push; only https, ssh and git:// are supported",
|
||||
"确认之后仓库又变了,请重新预览再推送": "The repository changed after you confirmed; preview again and then push",
|
||||
"远端有这个分支没有的提交,推送被拒绝(不会强制推送)": "The remote has commits this branch does not, so the push was rejected (it is never forced)",
|
||||
"远端需要认证,请先在 git 里配置凭据": "The remote needs authentication; set up a credential in git first",
|
||||
"推送失败了": "The push failed",
|
||||
"推送时 git 出错了": "git failed during the push",
|
||||
"这个仓库不属于当前工作区": "That repository is not part of this workspace",
|
||||
"无法列出这个工作区里的仓库": "Could not list the repositories in this workspace",
|
||||
"重新读取": "Read again",
|
||||
"这个文件夹不是 git 仓库": "This folder is not a git repository",
|
||||
"还没有提交": "No commits yet",
|
||||
@@ -470,6 +515,11 @@ export const EN: Record<string, string> = {
|
||||
"完整输出已保存({lines} 行 · {size}),模型按需读取": "Full output saved to disk ({lines} lines · {size}); the model reads it on demand",
|
||||
"已显示开头部分,模型可继续往下读": "Showing the start; the model can read on from there",
|
||||
"模型只收到 {kept},共 {size} — 其余未进入上下文": "The model received only {kept} of {size} — the rest never entered its context",
|
||||
"中间 {n} 个字符没有放在页面里,只显示了开头和结尾": "{n} characters from the middle are not held in the page; only the start and end are shown",
|
||||
"显示完整输出": "Show full output",
|
||||
"正在取回…": "Fetching…",
|
||||
"取回失败,请重试": "Could not fetch it; try again",
|
||||
"此前省略了 {n} 个字符,这里只显示最近的输出": "{n} characters before this are left out; only the most recent output is shown",
|
||||
"在 {name} 下新建会话": "Start a new session in {name}",
|
||||
"关闭这个会话面板": "Close this pane",
|
||||
"不会删除任何文件": "No files are deleted",
|
||||
@@ -751,7 +801,8 @@ export const EN: Record<string, string> = {
|
||||
"已运行,但退出码来自命令中的其他片段": "it ran, but the exit code came from another stage of the command",
|
||||
"有改动;没有主机能读作证据的检查": "changed; no check the host could read as proof",
|
||||
"改动后未再检查": "changed, never looked at again",
|
||||
"有 Claude Code mod 在运行;它写了什么不在主机观测范围内": "a Claude Code mod ran; what it wrote is outside what the host observes",
|
||||
"有 Claude Code mod 在运行;它写了什么不在主机观测范围内": "a Claude Code mod was active; what it wrote is outside what the host observes",
|
||||
"你在等待期间,另一个会话改动了本会话占用的路径;之前读到的内容可能已过时": "another session wrote under this session's paths while it waited on you; earlier reads there may be stale",
|
||||
"自己申报未验证": "declared unverified by the turn itself",
|
||||
"同一账号的两种接入方式。当前这一种不支持联网搜索;这是协议差异,不是可配置项。": "Two ways in to the same account. This one has no web search — that is a difference between the protocols, not a setting.",
|
||||
"同一账号的两种接入方式。切换后下方的模型列表随之改变。": "Two ways in to the same account. Switching changes the model list below.",
|
||||
@@ -1310,6 +1361,15 @@ export const EN: Record<string, string> = {
|
||||
// ── 内核其余的拒绝措辞 ─────────────────────────────────────────
|
||||
"该供应商拒绝了当前密钥,无法读取余额": "The provider refused this key, so the balance cannot be read",
|
||||
"该供应商的余额接口无响应": "The provider's balance endpoint did not answer",
|
||||
"余额地址": "Balance address",
|
||||
"余额格式": "Balance format",
|
||||
"留空则不显示余额。密钥只会发给这个地址,不会跟随跳转。": "Leave empty to show no balance. The key is sent only to this address and never follows a redirect.",
|
||||
"供应商自己的域名会按它的格式读取;其他地址(例如中转站)必须选择它返回的格式,否则不读取余额。": "A vendor's own domain is read in that vendor's format; any other address (a relay, for example) must name the format it answers in, or no balance is read.",
|
||||
"按地址推断({format})": "From the address ({format})",
|
||||
"DeepSeek 格式": "DeepSeek format",
|
||||
"Moonshot 格式": "Moonshot format",
|
||||
"OpenRouter 格式": "OpenRouter format",
|
||||
"余额格式未配置:这个地址不是供应商自己的域名,选择格式之前不会读取余额。": "Balance format not set: this address is not the vendor's own domain, so no balance is read until a format is chosen.",
|
||||
"无法解析该供应商余额接口返回的内容": "The provider's balance endpoint answered in a shape this build cannot read",
|
||||
|
||||
"{n} 个问题": "{n} questions",
|
||||
@@ -1473,6 +1533,16 @@ export const EN: Record<string, string> = {
|
||||
"含赠金 {amount}": "incl. {amount} credit",
|
||||
"刚刚": "just now",
|
||||
"{n} 分钟前": "{n} min ago",
|
||||
"高峰": "Peak",
|
||||
"平峰": "Off-peak",
|
||||
"现在按供应商公布的高峰价计费": "Billed at the vendor's published peak rate now",
|
||||
"现在按供应商公布的平峰价计费": "Billed at the vendor's published off-peak rate now",
|
||||
"现在按供应商公布的高峰价计费,约 {left} 后恢复平峰价": "Billed at the vendor's published peak rate now; off-peak again in about {left}",
|
||||
"现在按供应商公布的平峰价计费,约 {left} 后进入高峰": "Billed at the vendor's published off-peak rate now; peak starts in about {left}",
|
||||
"{n} 分钟": "{n} min",
|
||||
"{n} 小时": "{n} h",
|
||||
"{h} 小时 {m} 分钟": "{h} h {m} min",
|
||||
"{n} 天": "{n} d",
|
||||
"{n} 小时前": "{n} h ago",
|
||||
"行宽": "Line width",
|
||||
"正文行宽": "Body line width",
|
||||
|
||||
@@ -196,4 +196,5 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"资源": "resource",
|
||||
"↑↓ 选择 · 回车引用 · Tab 补全或进入目录 · Esc 关闭": "↑↓ selects · Enter references · Tab completes or enters a folder · Esc closes",
|
||||
"↑↓ 选择 · Tab 或回车补全 · Esc 关闭": "↑↓ selects · Tab or Enter completes · Esc closes",
|
||||
"重新选择文件夹": "Choose the folder again",
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
export const EN_COMPOSITION: Record<string,string> = {
|
||||
"显示模式选择器": "Show mode picker",
|
||||
"默认关闭。开启后在聊天输入框顶部显示模式选择器;关闭只隐藏入口,不改变会话模式。": "Off by default. Enable to show the mode picker above the chat input. Turning it off hides the picker without changing the conversation's mode.",
|
||||
"无法保存模式选择器设置,请检查本地存储权限。": "Could not save the mode picker setting. Check local storage permissions.",
|
||||
"标准模式": "Standard mode",
|
||||
"精简模式": "Minimal mode",
|
||||
"处理代码、文件和资料,适合大多数任务。创建会话时继承当前配置,按需检索、编辑和运行工具。": "Work with code, files and research for everyday tasks. Inherit current configuration when creating the conversation, then search, edit and run tools as needed.",
|
||||
"只使用终端和图片读取工具,保留上下文压缩,适合基础任务与效果对比。需要支持读图的模型;终端操作仍受现有权限约束。": "Use terminal and image-reading tools with context compaction for basic tasks and comparisons. Requires a vision-capable model; terminal operations follow existing permissions.",
|
||||
"新任务默认": "New task default",
|
||||
"将{name}设为新任务默认": "Set {name} as the new task default",
|
||||
"如何使用 {name}": "How to use {name}",
|
||||
"点击卡片,设置本机新任务的默认模式;已有会话保持当前配置。": "Click a card to set the default for new tasks on this machine. Existing conversations keep their configuration.",
|
||||
"无法保存新任务默认模式,请检查本地存储权限。": "Could not save the new task default. Check local storage permissions.",
|
||||
"初次使用会选择日常搭档,直接说说你想做什么。你也可以点击卡片更改新任务默认,或复制一份自定义。": "Everyday partner is the initial default. Describe what you want to do, click a card to change the new-task default, or duplicate a preset to customize it.",
|
||||
"在新任务输入框选择轻装搭档,再发送首条消息。需要支持读图的模型;终端操作继续遵守权限设置。": "Choose Lightweight partner in a new task's composer before sending the first message. A vision-capable model is required; terminal operations follow existing permissions.",
|
||||
"预设版本": "Preset version",
|
||||
"继承当前配置": "Inherit current configuration",
|
||||
"返回预设列表": "Back to presets",
|
||||
"基于{name}创建": "Based on {name}",
|
||||
"用途说明": "Purpose",
|
||||
"人设与要求": "Persona and instructions",
|
||||
"描述预设的角色、工作方式与要求;内容写在系统提示词开头。": "Describe the role, approach and requirements. This text goes at the start of the system prompt.",
|
||||
"能力配置": "Capabilities",
|
||||
"继承当前的工具、技能与扩展配置,可按需单独调整。": "Inherit current tools, skills and extensions, or adjust them individually.",
|
||||
"技能与扩展": "Skills and extensions",
|
||||
"仅使用所选项": "Selected modules only",
|
||||
"自动生成,用于保存和引用预设;保存后不可修改。": "Generated automatically to save and reference this preset. Cannot be changed after saving.",
|
||||
"预设详情": "Preset details",
|
||||
"编辑预设": "Edit preset",
|
||||
"保存后供新会话选择,已有会话保持原配置。": "Available to new conversations after saving. Existing conversations keep their configuration.",
|
||||
"放弃未保存的更改?": "Discard unsaved changes?",
|
||||
"继续编辑": "Keep editing",
|
||||
"放弃更改": "Discard changes",
|
||||
"编辑日常搭档人设": "Edit Everyday partner persona",
|
||||
"显式默认配置": "Explicit default configuration",
|
||||
"仅在显式选择 default 时使用,与本机卡片设置的新任务默认独立。": "Used only when explicitly selecting default, separately from this machine's new-task card selection.",
|
||||
"预设已删除,已有会话保持原版本。": "Preset deleted. Existing conversations keep their version.",
|
||||
"导入的预设": "Imported preset",
|
||||
"新会话默认使用日常搭档。选择其他模式后才会启用对应预设。": "New conversations start in Everyday partner. Other presets apply only when selected.",
|
||||
"查看 {name}": "View {name}",
|
||||
"编辑 {name}": "Edit {name}",
|
||||
"复制 {name}": "Duplicate {name}",
|
||||
"日常搭档人设": "Everyday partner persona",
|
||||
"修改用户配置,与自定义预设独立保存。": "Edit user configuration, saved separately from custom presets.",
|
||||
"新会话及现有会话下一次构建时生效。": "Applies to new conversations and when existing conversations are next rebuilt.",
|
||||
"取消删除": "Cancel deletion",
|
||||
"会话文件无效,请选择完整的原生会话导出文件。": "Invalid session file. Choose a complete native session export.",
|
||||
"不支持此会话文件版本,请使用兼容版本导出。": "Unsupported session file version. Export it with a compatible version.",
|
||||
"会话文件超过安全导入限制(大小或记录数)。": "The session file exceeds safe import limits (size or record count).",
|
||||
"Agent 预设暂不可用": "Agent presets unavailable",
|
||||
"当前会话已绑定预设,日常搭档的人设修改不会更新此绑定。": "This conversation is bound to a preset. Editing the Everyday partner persona will not update that binding.",
|
||||
"默认使用日常搭档。其他模式仅在明确选择后启用;首选标记不会自动切换会话。": "Everyday partner is used by default. Other modes require an explicit choice; a preference never switches a conversation automatically.",
|
||||
"日常搭档": "Everyday partner",
|
||||
"轻装搭档": "Lightweight partner",
|
||||
"写代码、查资料、理清思路,和你一起把事情做好。沿用你的工具与配置,按需检索、编辑和运行。": "Write code, find information and think things through together. Use your tools and configuration to search, edit and run as needed.",
|
||||
"带上终端和看图工具,轻装开工。和你一起处理小任务、快速试验,也方便对比模型表现。": "Pack a terminal and image-reading tools, and get going. Work through small tasks and quick experiments together, or compare model performance.",
|
||||
"首选预设": "Preferred preset",
|
||||
"模式说明": "Mode details",
|
||||
"如何使用": "How to use",
|
||||
"新建自定义预设": "New custom preset",
|
||||
"在输入框顶部选择 Agent 模式,首次提交前可切换。开始对话后,请用其他模式新建会话。轻装搭档的终端仍可修改文件,并非只读模式。": "Choose an Agent mode above the input before the first message. Once a conversation starts, create another to use a different mode. Lightweight partner can still edit files through its terminal; it is not read-only.",
|
||||
"导入原生会话": "Import a native session",
|
||||
"保留组合版本与资源;导入不会安装程序或授予权限。缺少授权的会话仅可查看历史。": "Keep the composition version and resources. Importing installs no programs and grants no permissions. Sessions without authorization open for history only.",
|
||||
"选择会话 JSON 文件": "Choose a session JSON file",
|
||||
"会话文件超过大小限制。": "The session file exceeds the size limit.",
|
||||
"会话已导入,绑定版本保持不变。": "Session imported with its original composition version.",
|
||||
"通用": "General",
|
||||
"代码审查": "Code review",
|
||||
"资料研究": "Research",
|
||||
"继承当前配置,可读取、执行和修改;操作继续遵守现有权限。": "Inherit current configuration. Read, execute and edit within existing permissions.",
|
||||
"读取和检索代码,依据证据审查;不运行 shell、不修改文件。": "Read and search code for evidence-based reviews. Shell execution and file edits are disabled.",
|
||||
"读取代码和文档,搜索与抓取网页;不运行 shell、不修改文件。": "Read code and documents, search and fetch web pages. Shell execution and file edits are disabled.",
|
||||
"Agent 组合": "Agent composition",
|
||||
"该会话已开始,模式已锁定。请用其他模式新建会话。": "This conversation has started and its mode is locked. Start another conversation to use a different mode.",
|
||||
"首次提交前可切换 Agent 组合": "Choose an Agent composition before the first message",
|
||||
"原有会话配置": "Legacy session configuration",
|
||||
"管理 Agent 预设": "Manage Agent presets",
|
||||
"副本": "Copy",
|
||||
"Agent 预设": "Agent presets",
|
||||
"为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。": "Combine a persona, tools, skills and extensions for each conversation. Models stay independent; started conversations keep their original version.",
|
||||
"读取预设中…": "Loading presets…",
|
||||
"查看预设": "View preset",
|
||||
"未保存的预设": "Unsaved preset",
|
||||
"新建预设": "New preset",
|
||||
"复制为自定义预设": "Duplicate as custom preset",
|
||||
"已锁定": "Locked",
|
||||
"首次提交前可切换": "Switch before the first message",
|
||||
"当前会话使用旧版": "This conversation uses an older version",
|
||||
"预设 ID": "Preset ID",
|
||||
"存放范围": "Storage scope",
|
||||
"个人:保存在本机,可跨项目使用。项目共享:保存在当前项目,可随仓库共享。": "Personal: saved on this machine for use across projects. Shared with project: saved in the current project and shareable through its repository.",
|
||||
"用于保存和引用,区别于显示名称。例如 code-review,保存后不可修改。": "Used to save and reference the preset, separately from its display name. For example, code-review. Cannot be changed after saving.",
|
||||
"个人": "Personal",
|
||||
"项目共享": "Shared with project",
|
||||
"内置预设只读,复制后即可编辑。": "Built-in presets are read-only. Duplicate one to edit it.",
|
||||
"已保存,新会话使用新版": "Saved. New conversations use the updated version.",
|
||||
"保存预设": "Save preset",
|
||||
"已设为个人首选预设": "Set as personal preference",
|
||||
"设为个人首选": "Set personal preference",
|
||||
"已设为当前项目首选预设": "Set as workspace preference",
|
||||
"设为项目首选": "Set workspace preference",
|
||||
"导出文件": "Export file",
|
||||
"删除预设": "Delete preset",
|
||||
"删除后不再供新会话选择,已有会话保留绑定版本。": "Deleting removes it from new conversations. Existing conversations retain their bound version.",
|
||||
"导入预设文件(先预览,再保存)": "Import a preset file (preview before saving)",
|
||||
"导入预览已就绪,检查模块后保存。": "Import preview is ready. Check the modules before saving.",
|
||||
"下载预设 JSON": "Download preset JSON",
|
||||
"预设名称": "Preset name",
|
||||
"预设说明": "Preset description",
|
||||
"注入运行环境与离线提示": "Include runtime context and offline hints",
|
||||
"选择方式": "Selection mode",
|
||||
"创建会话时继承当前配置": "Inherit current configuration when creating the conversation",
|
||||
"仅使用所选项(空列表表示禁用)": "Use selected modules only (empty disables them)",
|
||||
"独立参数(JSON);env 和 headers 仅填写 ${NAME} 引用": "Per-composition parameters (JSON); use only ${NAME} references in env and headers",
|
||||
"独立参数": "Parameters",
|
||||
"参数需要有效的 JSON 对象": "Parameters must be a valid JSON object",
|
||||
"添加模块": "Add module",
|
||||
"自动化 Hook": "Automation hooks",
|
||||
"安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。": "Safety hooks and permission rules always apply; this selection controls non-blocking automation only.",
|
||||
"斜杠命令": "Slash commands",
|
||||
"用其他组合新建会话": "Start a conversation with another composition",
|
||||
"组合资源不可用,当前会话仅可查看历史。": "Composition resources are unavailable. This conversation is open for history only.",
|
||||
"预设内容无效,请检查标识、模块和参数": "Invalid preset. Check its ID, modules and parameters.",
|
||||
"组合所需资源不可用,无法继续执行": "Required composition resources are unavailable; execution is blocked.",
|
||||
"会话已开始或正在工作,不能切换组合": "This conversation has started or is busy; its composition cannot be changed.",
|
||||
"预设已被其他操作修改,草稿已保留,请重新加载后再保存": "The preset changed elsewhere. Your draft is retained; reload before saving.",
|
||||
"找不到该 Agent 预设": "Agent preset not found",
|
||||
"内置预设只读,请复制后编辑": "Built-in presets are read-only. Duplicate one to edit it.",
|
||||
};
|
||||
@@ -85,7 +85,6 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"随反馈一起发送的环境信息": "Environment sent with your report",
|
||||
"入口": "Surface",
|
||||
"渠道": "Channel",
|
||||
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
|
||||
"打开日志文件夹": "Open logs folder",
|
||||
"维护者向你要日志时,打开这个文件夹,把最新的 shell.log 和 host.log 发给我们。发之前可以先自己看一眼。": "When a maintainer asks for logs, open this folder and send us the newest shell.log and host.log. You can read them first.",
|
||||
"无法打开日志文件夹": "The logs folder could not be opened.",
|
||||
@@ -126,6 +125,13 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"撤回后维护者不再处理它,对应的 #{n} 议题会被关闭,你上传的图片会被删除;GitHub 上已经公开的文字会保留。": "Once withdrawn the maintainers stop working on it, its issue #{n} is closed and the images you uploaded are deleted; text already public on GitHub stays.",
|
||||
"撤回后维护者不再处理它,你上传的图片会被删除。": "Once withdrawn the maintainers stop working on it and the images you uploaded are deleted.",
|
||||
"来自其他设备": "From another device",
|
||||
"你的反馈等级升到了 L{n}「{name}」。": "Your feedback level went up to L{n} \u00b7 {name}.",
|
||||
"再落地 {r} 条升到「{next}」。": "{r} more shipped to reach {next}.",
|
||||
"没能记住这条已读,稍后会再提示。": "That could not be remembered; it will be shown again later.",
|
||||
"等级提示": "Level notices",
|
||||
"你的反馈被采纳、等级提高时,在窗口顶部提示一次,由你点“知道了”关掉。默认关闭;关着时不会提示,也不会补报之前的升级。": "When your feedback ships and your level goes up, a note appears once at the top of the window until you dismiss it. Off by default; while off nothing is said, and earlier level-ups are not announced when you turn it on.",
|
||||
"等级提升时通知我": "Tell me when my level goes up",
|
||||
"不弹窗、不发系统通知,只在窗口顶部的提示条里说一次": "No pop-up and no system notification: one line in the note bar at the top of the window",
|
||||
"已读标记只记在这台电脑上,其他设备上的反馈在这里仍可能显示为新回复。": "Read marks are kept on this computer only, so a report from another device may still show new replies here.",
|
||||
"需要你回复": "Needs your reply",
|
||||
"等你补充信息": "Waiting for your information",
|
||||
@@ -208,4 +214,38 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"这台电脑还没有发过反馈,发出第一条之后才能关联账号。": "This computer has not sent a report yet. You can link it to an account after the first one.",
|
||||
"账号关联暂时不可用,请稍后再试。": "Account linking is unavailable right now. Please try again later.",
|
||||
"操作没有完成,请稍后重试。": "That did not finish. Please try again later.",
|
||||
"{n} 条被采纳": "{n} adopted",
|
||||
"先在设置里登录账号,才能决定是否上榜。": "Sign in from Settings first to choose whether to be on the board.",
|
||||
"全部版本": "All releases",
|
||||
"加入贡献榜": "Join the board",
|
||||
"名次 {n}": "Place {n}",
|
||||
"名次取决于维护者选择发布的内容:被婉拒的好反馈不计入。": "A ranking depends on what the maintainers chose to ship: a good report that was declined earns nothing.",
|
||||
"我在榜上": "My place on the board",
|
||||
"榜上将显示为:{name}": "It will be shown on the board as: {name}",
|
||||
"榜上显示为": "Shown on the board as",
|
||||
"榜上显示的名字": "Name shown on the board",
|
||||
"给反馈被采纳并发布的人。按版本看有名次,全部版本只按名字排序,不比高低。只有自己选择加入的人才会出现。": "For people whose feedback was adopted and shipped. A release has a ranking; all releases together are listed by name, not ranked. Only people who choose to join appear.",
|
||||
"贡献榜": "Contribution board",
|
||||
"内存": "Memory",
|
||||
"只读。不包含密钥、地址或任何文件内容。内存一项只有进程类型和上面这些数字,用来判断是哪一部分占用高。": "Read-only. It holds no key, address or file content. The memory line is only the process type and the numbers above, to tell which part is using a lot.",
|
||||
"{name}:{set}(私有 {own})": "{name}: {set} (private {own})",
|
||||
"主进程": "Main process",
|
||||
"界面进程": "Interface process",
|
||||
"图形进程": "Graphics process",
|
||||
"辅助进程": "Helper process",
|
||||
"内核": "Kernel",
|
||||
"要不要加入贡献榜": "Join the contribution board?",
|
||||
"要不要加入贡献榜?": "Join the contribution board?",
|
||||
"这台电脑已关联账号。加入后榜上公开显示你选的名字和被采纳的条数,不显示邮箱、安装 ID 或路径;这个提示只出现一次,随时可以退出。": "This computer is linked to your account. If you join, the board publicly shows the name you choose and how many of your suggestions were adopted, never an email, install ID or path. This is asked only once, and you can leave any time.",
|
||||
"加入后榜上只显示下面这个名字和采纳数,不显示邮箱、安装 ID 或路径;随时可以退出,退出后下一次刷新就不再出现。": "Once you join, the board shows only the name below and your adopted count, never an email, install ID or path. You can leave any time and the entry is gone at the next refresh.",
|
||||
"榜是公开的:任何人都能看到下面这个名字和被采纳的条数,看不到邮箱、安装 ID 或路径。随时可以在贡献榜里退出。": "The board is public: anyone can see the name below and how many suggestions were adopted, but never an email, install ID or path. You can leave from the contribution board any time.",
|
||||
"请填写一个名字,或取消勾选就不加入。": "Enter a name, or untick the box to not join.",
|
||||
"名字至少 2 个字符。": "The name needs at least 2 characters.",
|
||||
"名字最多 30 个字符。": "The name can have at most 30 characters.",
|
||||
"名字只能用字母、数字、空格、下划线或连字符,不能含 @ 或句点。": "The name may use letters, digits, spaces, underscores or hyphens only, with no @ or period.",
|
||||
"已关联,但没能加入贡献榜:{why}。可以在下面的贡献榜里改个名字再加入。": "Linked, but joining the board did not work: {why}. You can pick another name in the contribution board below.",
|
||||
"确认关联": "Confirm linking",
|
||||
"这个版本还没有人上榜。": "Nobody is listed for this release yet.",
|
||||
"这个账号已被维护者从榜单移除,暂时不能再加入。": "A maintainer removed this account from the board; it cannot join again for now.",
|
||||
"退出榜单": "Leave the board",
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
// code adds one here. i18n.test.ts is what stops one from being forgotten.
|
||||
|
||||
export const EN_KERNEL: Record<string, string> = {
|
||||
"此会话的组合已绑定原项目,请在目标项目新建会话": "This conversation's composition is bound to its project. Start a new conversation in the target project",
|
||||
"起始分支已不存在,请重新选择分支": "The source branch no longer exists. Choose another branch",
|
||||
"会话或工作目录已改变,请新建会话后重新选择 worktree": "The session or workspace changed. Start a new session to select a worktree",
|
||||
"请先打开一个本机会话,再读取本机设置": "Open a local session to read this machine’s settings",
|
||||
@@ -46,7 +47,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
|
||||
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
|
||||
"搜索模型「{model}」的格式应为 服务/模型": "Search model “{model}” must be written as service/model",
|
||||
"搜索模型「{model}」所在的连接已被移出": "Search model “{model}”'s connection has been removed",
|
||||
"搜索模型「{model}」所在的连接还没有添加:先添加这个连接,再选它": "Search model “{model}”'s connection has not been added yet: add that connection first, then pick it",
|
||||
"搜索模型「{model}」已不在配置里": "Search model “{model}” is no longer configured",
|
||||
"搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索": "Search model “{model}” has no native search on its protocol, or search is turned off for it",
|
||||
"搜索模型「{model}」所在的连接没有凭据": "Search model “{model}”'s connection has no credentials",
|
||||
@@ -59,6 +60,11 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"需要先登录账号": "Sign in to your account first",
|
||||
"这台电脑已关联到另一个账号": "This computer is already linked to another account",
|
||||
"这个账号关联的电脑已达上限": "This account has reached its limit of linked computers",
|
||||
"名字需要 2 到 30 个字符:字母、数字、空格、下划线或连字符,不能含 @ 或网址": "A name is 2 to 30 letters, digits, spaces, underscores or hyphens, with no @ and no links",
|
||||
"这个名字是保留的,会让人误以为是维护者,换一个吧": "That name is reserved because it could be mistaken for a maintainer; pick another",
|
||||
"已经有人用这个名字了": "Someone already uses that name",
|
||||
"这个账号已被维护者从榜单移除,暂时不能再加入": "A maintainer removed this account from the board; it cannot join again for now",
|
||||
"先把这台电脑关联到账号,才能加入榜单": "Link this computer to your account first, then join the board",
|
||||
"这台电脑还没有发过反馈": "This computer has not sent a report yet",
|
||||
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
|
||||
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
|
||||
@@ -117,6 +123,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该会话的待送达已关闭": "This session's pending queue is closed",
|
||||
"该待送达由更高版本写入,当前版本只能读取": "This queue was written by a newer version and is read-only here",
|
||||
"这份待送达属于另一个运行,暂不能接管": "This pending queue belongs to another run and cannot be taken over here",
|
||||
"待送达的正文读不出来,队列内容未知,已暂停等待检查": "The pending queue's contents could not be read, so they are unknown and the queue is paused for inspection",
|
||||
"该提交标识已被使用,且当时的内容不同": "That submission key was already used, with different content",
|
||||
// 配置文件本身坏了,以及每一个写设置的面板被它挡住时
|
||||
"配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file {path} does not parse, so nothing was saved. Open Settings to see where it is broken and repair it",
|
||||
@@ -135,10 +142,16 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"计划批准设置未能保存:{detail}": "The plan approval setting was not saved: {detail}",
|
||||
"这台服务器未开放新对话起始模式设置": "This server does not open the new-conversation start mode setting",
|
||||
"新对话起始模式设置未能保存:{detail}": "The new-conversation start mode setting was not saved: {detail}",
|
||||
"这台服务器未开放工具设置": "This server does not open the tool settings",
|
||||
"工具设置未能保存:{detail}": "The tool setting was not saved: {detail}",
|
||||
"本次请求未说明要改哪个工具,未做任何修改": "The request did not say which tool to change, so nothing changed",
|
||||
"这个工具被其他功能需要,不能关闭": "Another feature needs this tool, so it cannot be turned off",
|
||||
"这个工具已在配置里关闭,本次调用未执行": "This tool is turned off in the configuration, so the call did not run",
|
||||
"子代理写入范围设置未能保存:{detail}": "The sub-agent write scope setting was not saved: {detail}",
|
||||
"这台服务器未开放子代理指示上限设置": "This server does not open the sub-agent guidance limit setting",
|
||||
"子代理指示上限设置未能保存:{detail}": "The sub-agent guidance limit setting was not saved: {detail}",
|
||||
"本次请求未说明选择,未做任何修改": "The request did not say which, so nothing changed",
|
||||
"最长等待的分钟数不能是负数": "The longest wait cannot be negative",
|
||||
"这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写": "This path is in use by another parallel sub-agent or write. This write did not run; choose another path, or wait until it finishes",
|
||||
"无法解析该服务器声明:{detail}": "That server declaration could not be read: {detail}",
|
||||
"未能安装该服务器:{detail}": "That server could not be installed: {detail}",
|
||||
@@ -193,7 +206,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"社区市场返回了无法识别的内容": "The community market answered with something unrecognisable",
|
||||
"社区市场暂不支持只列出已固定内容的包": "The community market cannot list only pinned packages yet",
|
||||
"该包的审核版本没有固定内容,需要信任发布者后才能安装": "The reviewed version is not pinned to its content; trust the publisher to install it",
|
||||
"该包的审核版本指向本机或不安全的来源,不从市场安装": "The reviewed version points at a local or unsafe source, which the market does not install",
|
||||
"该包的来源地址无效或不受支持,无法从市场安装;请发布者核对来源后重新提交": "This package's source URL is invalid or unsupported and cannot be installed from the market; ask the publisher to check the source and resubmit",
|
||||
"插件或主题的来源地址无效:必须使用 HTTPS GitHub 目录地址,并固定到完整的 40 位小写十六进制提交号(tree/提交号);请核对地址,修正后重新提交": "Invalid plugin or theme source URL: use an HTTPS GitHub directory URL pinned to a full 40-character lowercase hexadecimal commit (tree/commit); check the URL, correct it and resubmit",
|
||||
"审核版本已更新,请重新查看后再安装": "A newer version has been approved - look at it again before installing",
|
||||
"来源内容已与审核版本不同,已拒绝安装": "The source no longer holds the reviewed content, so nothing was installed",
|
||||
"该来源的内容无法固定,已拒绝安装": "This source cannot be pinned by content, so nothing was installed",
|
||||
@@ -255,10 +269,17 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"请为该机器填写名称": "Give this machine a name",
|
||||
"请填写要连接的地址": "Say which address to dial",
|
||||
"这不是有效的端口号": "That is not a port number",
|
||||
"这个窗口没有连到账号服务,不能管理手机": "This window is not connected to the account service, so it cannot manage phones",
|
||||
"这台电脑还没有登记到账号:先登录账号并打开远程访问": "This computer is not registered with an account yet: sign in and turn on remote access first",
|
||||
"找不到这台手机,它可能已经被移除": "That phone was not found; it may already have been removed",
|
||||
"账号服务拒绝了这个操作,请刷新后再试": "The account service refused that; refresh and try again",
|
||||
"连不上账号服务,请检查网络后重试": "Could not reach the account service; check the network and try again",
|
||||
"这台机器不是 SSH 主机,此表单不能修改它": "This machine is not an SSH host, so this form cannot change it",
|
||||
"这不是有效的 WSL 发行版名称": "That is not the name of a WSL distribution",
|
||||
"读不到已安装的 WSL 发行版": "Could not read which WSL distributions are installed",
|
||||
"这台机器的接入方式与表单不同(SSH 或 WSL),不能这样修改它": "This machine is reached another way than this form describes (SSH or WSL), so it cannot be changed like this",
|
||||
"该主机已停用,启用后才能连接": "This machine is turned off; turn it on to connect",
|
||||
"主机 {host} 尚未保存,请先添加该主机再连接": "Host {host} is not saved; add it before connecting",
|
||||
"该机器仍有 {n} 个打开的面板,请先关闭再移除": "This machine still has {n} open panes — close them before removing it",
|
||||
"{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。":
|
||||
"The host key for {host} changed. That machine may have been rebuilt \u2014 or someone may be in the middle. The record is at {file} line {line}; do not connect until you have checked.",
|
||||
@@ -331,6 +352,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.",
|
||||
"这个文件夹不在当前窗口的项目列表里。": "That folder is not a project in this window.",
|
||||
"项目文件夹已不在磁盘上。": "The project folder is no longer on disk.",
|
||||
"这次操作需要的文件夹已不在磁盘上:可能被移走、改名或删除了,操作没有执行。": "The folder this action needs is no longer on disk: it may have been moved, renamed or deleted, and nothing was run.",
|
||||
"这个内核不在本机,没法在系统文件管理器中显示它的文件。": "This kernel is not on this machine, so its files cannot be shown in the system file manager.",
|
||||
"这个系统没有可用的文件夹选择框,请直接填写路径。": "This system has no folder picker available; type the path instead.",
|
||||
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
|
||||
@@ -445,6 +467,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该插件未安装": "That plugin is not installed",
|
||||
"图片数据不是 base64 编码": "The image data is not base64",
|
||||
"HTTP 上不提供 shell 命令": "Shell commands are not served over HTTP",
|
||||
"这个会话里没有这条调用的输出": "This session has no output for that call",
|
||||
"要取的输出范围不对,请重新展开这条输出": "The part of the output asked for is not valid; open the output again",
|
||||
"不存在「{role}」这个角色": "There is no “{role}” role",
|
||||
"没有已配置的模型匹配「{model}」": "No configured model matches “{model}”",
|
||||
"「{model}」是决策来源,只服务 system_one 的询问,不能用作对话模型": "“{model}” is a decision source: it only answers system_one's questions and cannot be used as a conversation model",
|
||||
@@ -470,6 +494,9 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
|
||||
"项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加": "The project list is at the limit you set (desktop.max_workspaces); remove one before adding another",
|
||||
"{model} 的价格必须是不小于 0 的数字,并选择货币": "The price for {model} must be a number not below 0, with a currency chosen",
|
||||
"{model} 的价格来源必须是 http 或 https 链接,不能含用户名密码或控制字符": "The source of the price for {model} must be an http or https link with no credentials or control characters",
|
||||
"{model} 的核对日期必须是 YYYY-MM-DD,且不晚于今天(按 UTC)": "The check date for {model} must be YYYY-MM-DD and not later than today (UTC)",
|
||||
"计费方式只能是按量计费或订阅等价估算": "Billing is either pay-as-you-go or a subscription-equivalent estimate",
|
||||
"{model} 不在已选模型中,未保存价格": "{model} is not one of the selected models, so no price was saved",
|
||||
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
|
||||
"不存在该预设": "There is no such preset",
|
||||
@@ -503,6 +530,9 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"窗口大小已记录,将在本轮结束后生效": "The window is saved; it starts counting once the running work finishes",
|
||||
"已保存。当前对话还有未结束的工作(正在运行、等待你回答或有后台任务),仍按原设置进行;结束后再保存一次即可生效": "Saved. This conversation still has work in progress (running, waiting for your answer, or background jobs), so it keeps its current settings; save again once that finishes to apply them",
|
||||
"已保存。当前对话使用的模型已不在该来源的列表中,切换模型后才会生效": "Saved. This conversation runs on a model the source no longer lists; switch models to apply it",
|
||||
"余额格式未配置:这个地址不是供应商自己的域名,请在连接设置里选择它返回的格式": "Balance format not set: this address is not the vendor's own domain, so choose the format it answers in under the connection settings",
|
||||
"余额地址必须是 http 或 https 开头的网址": "The balance address must be an http or https URL",
|
||||
"这个余额格式不在可选范围内": "That balance format is not one of the available ones",
|
||||
"额外设置中的「{path}」不能为空值(null)": "“{path}” in the extra settings cannot be null",
|
||||
"图片过大,请压缩至 {limit} MB 以内": "That image is too large — bring it under {limit} MB",
|
||||
"{path} 不在当前工作树中,无法查看其改动": "{path} is not inside this working tree, so its changes cannot be shown",
|
||||
@@ -563,6 +593,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"运行时已更换,费用显示币种未保存,请重试": "The runtime changed; the display currency was not saved. Try again.",
|
||||
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
|
||||
"文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}",
|
||||
"等级提示的设置未能保存:{detail}": "The level notice setting was not saved: {detail}",
|
||||
"折叠中间过程的设置未能保存:{detail}": "The fold-the-work setting was not saved: {detail}",
|
||||
"回合间叙述的设置未能保存:{detail}": "The narration setting was not saved: {detail}",
|
||||
"这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。": "Editing the web search and fetch settings is not enabled on this server: only the window on this computer can change them.",
|
||||
|
||||
@@ -19,10 +19,10 @@ export const EN_PERSONA: Record<string, string> = {
|
||||
"已修改预设内容,按下面的字段保存。": "The preset text has been edited; save the fields below.",
|
||||
"系统提示词开头": "Start of the system prompt",
|
||||
"作为系统提示词的第一段,写在内置指令之前;留空表示不添加。": "Leads the system prompt, ahead of the built-in guidance; leave it empty to add nothing.",
|
||||
"第一方指令之后": "After the first-party guidance",
|
||||
"紧跟在内置指令之后、运行时上下文之前;留空表示不添加。": "Follows the built-in guidance and precedes the runtime context; leave it empty to add nothing.",
|
||||
"补充要求": "Additional instructions",
|
||||
"放在内置系统指令之后,留空则不添加": "Placed after the built-in system instructions. Leave blank to add nothing.",
|
||||
"不注入可选运行时上下文": "Do not inject optional runtime context",
|
||||
"关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
|
||||
"开启后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
|
||||
"写入用户配置,在新会话及现有会话下一次重建时生效;保存本身不会重建会话。": "Writes the user config; applies to new sessions and the next rebuild of an existing session. Saving itself does not rebuild it.",
|
||||
"已保存,下次会话构建时生效": "Saved; applies at the next session build",
|
||||
};
|
||||
@@ -44,6 +44,18 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"尚未添加远程机器。添加后,其工作区将与本地工作区并列显示在左栏。":
|
||||
"No remote machines yet. Add one and its workspaces appear in the sidebar beside your local ones.",
|
||||
"添加机器": "Add a machine",
|
||||
"手机通知": "Phone notifications",
|
||||
"有事等你时,电脑可以提醒登录了同一个账号的手机。发出去的只有“哪一类”和数量,不含会话名称、命令或任何对话内容;手机要在它自己的页面里选择接收。默认全部关闭。": "When something is waiting for you, this computer can remind phones signed in to the same account. Only the kind of occasion and a count are sent, never a session name, command or any conversation text, and a phone chooses on its own page whether to receive them. Everything is off by default.",
|
||||
"提醒我的手机": "Remind my phones",
|
||||
"关闭时电脑不会向账号服务报告任何事。立即生效": "When off, this computer reports nothing to the account service. Takes effect immediately.",
|
||||
"一轮失败时": "When a turn fails",
|
||||
"这一轮因为出错而结束;你自己取消的不算": "The turn ended in an error; one you cancelled yourself does not count",
|
||||
"已登记的手机": "Enrolled phones",
|
||||
"还没有手机登记。在手机上打开这台电脑的远程页面,并选择接收提醒。": "No phone is enrolled yet. Open this computer's remote page on a phone and choose to receive reminders.",
|
||||
"等你批准 · 标识 {id}": "Waiting for you · fingerprint {id}",
|
||||
"最近使用 {at}": "Last used {at}",
|
||||
"替换旧设备": "Replace the old device",
|
||||
"确认移除": "Confirm removal",
|
||||
"WSL 发行版": "WSL distribution",
|
||||
"SSH 主机": "SSH host",
|
||||
"连接类型": "Connection type",
|
||||
@@ -51,6 +63,8 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"{name}(WSL 1,不支持)": "{name} (WSL 1, not supported)",
|
||||
"内核运行在 Studio 保持的一个会话里:关闭 Studio 后它会随发行版一起停止,对话保存在磁盘上,下次连接时继续。模型凭据使用发行版自己的配置。": "The kernel runs in a session Studio keeps open: when Studio closes, it stops with the distribution. Conversations are kept on disk and continue on the next connect. Model credentials come from the distribution's own configuration.",
|
||||
"未设置默认工作区": "No default workspace",
|
||||
"默认连接目录:{path}": "Default connection directory: {path}",
|
||||
"连接目录:{path}": "Connected directory: {path}",
|
||||
"跟随 ssh_config": "Follows ssh_config",
|
||||
"{n} 条转发": "{n} forwards",
|
||||
"{n} 个面板": "{n} panes",
|
||||
@@ -235,4 +249,26 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"断开": "Disconnect",
|
||||
"还没有手机连上来。": "No phone is connected yet.",
|
||||
"连接走的是局域网里的明文 HTTP:同一个网络里的其他人能看到传输内容。在自己家里可以用,咖啡馆、机场这类公共网络不要开。Tailscale 地址的流量本身是加密的。": "The connection is plain HTTP on your local network, so others on the same network can read it. Fine at home; do not turn it on at a café, an airport or any other public network. Traffic to a Tailscale address is already encrypted.",
|
||||
"这台手机的提醒": "This phone's reminders",
|
||||
"电脑有事等你时,这台手机会收到一条通知。通知里只有“哪一类”和数量,不含会话名称、命令或对话内容。默认关闭,只提醒你勾选的几类。": "When something on your computer is waiting for you, this phone gets a notification. It carries only the kind of occasion and a count, never a session name, command or conversation text. Off by default, and only the occasions you tick are sent.",
|
||||
"这个浏览器没有通知能力。iPhone 上通常要先把这个页面添加到主屏幕,再从主屏幕打开。": "This browser cannot receive notifications. On an iPhone you usually need to add this page to the Home Screen and open it from there.",
|
||||
"提醒这台手机": "Remind this phone",
|
||||
"第一次打开时,这台手机会向电脑申请登记,需要你在电脑上批准。": "The first time you switch this on, the phone asks your computer to register it, and you approve that on the computer.",
|
||||
"这台手机在等电脑批准:到电脑上的 设置 → 远程 → 手机通知,点「添加」,然后回来再试。": "This phone is waiting for your computer to approve it: on the computer open Settings → Remote → Phone notifications, press Add, then come back and try again.",
|
||||
"再试一次": "Try again",
|
||||
"电脑已经把这台手机移除了。重新登记会换一把新的密钥,并需要电脑再批准一次。": "The computer has removed this phone. Registering again makes a new key and needs the computer to approve it once more.",
|
||||
"重新登记": "Register again",
|
||||
"有操作要批准": "An action needs approval",
|
||||
"模型要动的东西超出了已经给出的授权": "The model wants to touch something beyond what it was authorised for",
|
||||
"模型向我提问": "The model asks me something",
|
||||
"模型停下来等你回答": "The model has stopped and is waiting for your answer",
|
||||
"一轮出错结束": "A turn ends in an error",
|
||||
"上一次提醒没有送达(推送服务回应 {status}),已自动放慢重试。": "The last reminder was not delivered (the push service answered {status}); retries have been slowed down.",
|
||||
"上一次提醒没有送达,已自动放慢重试。": "The last reminder was not delivered; retries have been slowed down.",
|
||||
"浏览器没有允许通知。请在浏览器的网站设置里允许这个页面发通知,再打开这个开关。": "The browser did not allow notifications. Allow them for this page in the browser's site settings, then switch this on again.",
|
||||
"这个浏览器不能接收提醒。": "This browser cannot receive reminders.",
|
||||
"这台手机还没有被电脑批准。": "This phone has not been approved by the computer yet.",
|
||||
"需要重新登录账号后再试。": "Sign in to your account again, then try again.",
|
||||
"账号服务暂时不可用,请稍后再试。": "The account service is unavailable right now; try again later.",
|
||||
"浏览器没能向它的推送服务登记,所以这台手机暂时收不到提醒。常见原因是网络连不上浏览器厂商的推送服务。": "The browser could not register with its push service, so this phone cannot get reminders for now. A common cause is a network that cannot reach the browser maker's push service.",
|
||||
};
|
||||
@@ -21,6 +21,13 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"写下要告诉另一个会话的话。对方的用户批准后,它才会交给那边的模型。": "Write what to tell the other session. It reaches that session\u2019s model only after its user approves.",
|
||||
"发送中…": "Sending\u2026",
|
||||
"会话间消息(实验)": "Messages between sessions (experimental)",
|
||||
"会话间消息": "Session messages",
|
||||
"放行的项目": "Projects allowed through",
|
||||
"放行这个项目": "Allow this project through",
|
||||
"还没有放行任何项目:每条收到的消息都会先问你。": "No project is allowed through yet: every message that arrives asks you first.",
|
||||
"先启用会话间消息,再放行项目。": "Turn on session messages first, then allow projects through.",
|
||||
"没有可以放行的项目。只能从你已经添加过的项目里选。": "There is no project to allow. You can only pick from the projects you have already added.",
|
||||
"这些项目里的会话收到你其他会话的消息时不再询问,直接交给模型。只写在你自己的用户配置里,项目配置改不了;随时可以撤销。": "Sessions in these projects take a message from your other sessions without asking and hand it to the model. It is written only in your own user config, which a project's config cannot change, and you can take it back at any time.",
|
||||
"同一台机器上的会话互相发文字。收到的文字先挂起,只有你批准才会交给模型;发出去的文字也要对方的用户批准。没有给模型用的工具,默认关闭。": "Sessions on one machine send each other text. What you receive is held until you approve it; what you send needs the other user\u2019s approval too. There is no tool for the model, and it is off by default.",
|
||||
"浏览器标签": "Browser tabs",
|
||||
"辅助会话": "Side question",
|
||||
@@ -412,12 +419,24 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"新对话默认进入计划模式": "Start new conversations in plan mode",
|
||||
"新建的对话先做计划,你批准后才动手。输入框里的模式按钮仍可随时切换;已有的对话保持它自己的模式。默认关闭,只写在你自己的用户配置里,项目配置不能替你改。": "A new conversation plans first and acts only after you approve. The mode button in the composer still switches it at any time, and a conversation that already exists keeps its own mode. Off by default, written only to your own user config; a project config cannot change it for you.",
|
||||
"无法读取新对话的起始模式设置。": "Could not read the setting for how new conversations start.",
|
||||
"模型可用的工具": "Tools the model can use",
|
||||
"关掉的工具不会再出现在发给模型的请求里,模型也调用不了它;可以省下每次请求里的提示词开销。新开的对话生效,已有对话不受影响。": "A tool you turn off is no longer sent to the model, and a call to it is refused; this also saves its prompt cost on every request. It takes effect in conversations you open afterwards and never changes one that already exists.",
|
||||
"无法读取工具列表。": "Could not read the tool list.",
|
||||
"{feature}需要它,不能关闭。": "{feature} needs it, so it cannot be turned off.",
|
||||
"目标模式": "Goal mode",
|
||||
"计划步骤跟踪": "Plan step tracking",
|
||||
"结束一个宿主留着没完的回合": "Ending a turn the host left open",
|
||||
"这个项目的配置文件把它关掉了;要重新打开,请在项目的 reasonix.toml 里去掉。": "This project's config file turned it off; to turn it back on, remove it from the project's reasonix.toml.",
|
||||
"加载延后的 MCP 工具": "Loading deferred MCP tools",
|
||||
"计划模式提交计划后,默认等你点批准才开始执行。长时间无人值守的任务可以改成自动批准。": "After Plan mode submits a plan, it waits for your approval by default. For long unattended runs it can be set to approve automatically.",
|
||||
"自动批准计划": "Approve plans automatically",
|
||||
"计划提交后不再等你点批准,直接开始执行,对话里会留下一条说明。只放行这一次点击:工具批准、沙箱和拒绝规则照常生效。默认关闭,只写在你自己的用户配置里,项目配置不能替你打开。": "A submitted plan starts without waiting for your click, and the transcript notes it. Only that click is released: tool approvals, the sandbox and deny rules still apply. Off by default, written only to your own user config; a project config cannot turn it on for you.",
|
||||
"多个会话同时写一个项目": "Several sessions writing one project",
|
||||
"多个会话在同一个项目里工作时如何互相等待。默认严格:没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)会占住整个工作区,其他会话要等它结束。只影响之后新开的会话,已在运行的会话保持它启动时的设置。": "How sessions working in one project wait for each other. Strict by default: an operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) holds the whole workspace and other sessions wait for it. It affects sessions opened afterwards; a session already running keeps the setting it started with.",
|
||||
"乐观模式(多会话并行)": "Optimistic mode (sessions in parallel)",
|
||||
"等待其他会话释放写入权的最长时间": "Longest wait for another session to release the write claim",
|
||||
"另一个会话占着要写的路径时,这里的写入会一直等;设了分钟数,超过就失败,并给模型一个 workspace.claim_timeout 的明确原因。默认 0,一直等到本轮结束;想设上限,建议 120。之后新开的会话才采用新值。": "While another session holds a path this write needs, the write waits. With a number of minutes set, it fails past that and the model is told why with workspace.claim_timeout. Default 0 waits as long as the turn lasts; 120 is a sensible limit. Only sessions opened afterwards use it.",
|
||||
"分钟": "minutes",
|
||||
"没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)不再占住整个工作区,所以同一个项目里的多个会话可以同时工作,不必互相等待。代价:声明了路径的写入(如编辑文件)仍按路径互斥,但 bash 和 MCP 的写入没有事前的路径围栏,只在事后观察,两个会话用它们改同一个文件时可能互相覆盖。默认关闭(严格模式);只对之后新开的会话生效。": "An operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) no longer holds the whole workspace, so several sessions in one project can work at the same time without waiting. The cost: writes that declare a path (such as editing a file) still exclude each other by path, but bash and MCP writes have no path fence beforehand and are only observed afterwards, so two sessions using them on one file can overwrite each other. Off by default (strict mode); applies only to sessions opened afterwards.",
|
||||
"无法读取子代理写入范围设置。": "Could not read the sub-agent write scope setting.",
|
||||
"子代理的写入范围": "Sub-agent write scope",
|
||||
@@ -451,6 +470,17 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"{n} 个档位": "{n} levels",
|
||||
"{n} 个模型单独设置": "{n} models with their own levels",
|
||||
"按模型设置价格": "Price by model",
|
||||
"来源与核对日期": "Source and check date",
|
||||
"核对日期": "Checked on",
|
||||
"{model} 的价格来源": "Source of the price for {model}",
|
||||
"{model} 的核对日期": "Check date of the price for {model}",
|
||||
"{model} 打开价格来源": "Open the price source of {model}",
|
||||
"来源必须是 http 或 https 链接,不能带用户名密码。": "The source must be an http or https link without a username or password.",
|
||||
"核对日期不能晚于今天(按 UTC)。": "The check date cannot be later than today (UTC).",
|
||||
"核对于 {date}(UTC)": "Checked on {date} (UTC)",
|
||||
"等价估算": "API-priced estimate",
|
||||
"按 API 价格估算的订阅用量,不是实际扣费": "Subscription use priced at API rates; not a charge",
|
||||
"订阅用量按 API 价格估算,不是实际扣费,也不计入上面的实付": "Subscription use priced at API rates. It is not a charge and is not part of the paid figure above.",
|
||||
"这个价格不在官方价目表里,选了人民币或美元显示时无法换算。": "This price is not in the vendor's price list, so it cannot be converted when a display currency is chosen.",
|
||||
"缓存命中价是 0:命中的 token 会按免费计,费用会偏低。": "The cache-hit price is 0: cached tokens are counted as free, so cost reads too low.",
|
||||
"{model} 恢复官方价": "{model} restore official price",
|
||||
@@ -1277,7 +1307,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"从 Reasonix 1.x 迁移过来的会话是否出现在侧栏列表里。隐藏后不会删除它们,搜索时仍能找到,正在打开的旧会话也照常显示。默认显示。": "Whether sessions migrated from Reasonix 1.x appear in the sidebar lists. Hiding does not delete them; search still finds them and an open one stays visible. Shown by default.",
|
||||
"显示": "Show",
|
||||
"会话行快捷按钮": "Session row buttons",
|
||||
"鼠标移到侧栏会话行上时,是否在右侧显示「归档」「删除」按钮。关闭时这两个操作仍在右键菜单里。默认关闭。": "Whether the Archive and Delete buttons appear at the right of a sidebar session row while the pointer is over it. When off, both stay in the row's context menu. Off by default.",
|
||||
"悬停或键盘聚焦会话时,左侧显示置顶按钮,右侧显示归档按钮。默认关闭;关闭时可通过会话菜单操作。": "Show Pin on the left and Archive on the right when a session is hovered or focused. Off by default; when hidden, use the session menu.",
|
||||
"置顶会话:{title}": "Pin session: {title}",
|
||||
"取消置顶:{title}": "Unpin session: {title}",
|
||||
"归档会话:{title}": "Archive session: {title}",
|
||||
"取消归档:{title}": "Unarchive: {title}",
|
||||
"隐藏": "Hide",
|
||||
@@ -1347,6 +1379,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"解析超时": "Parsing timed out",
|
||||
"不是本宿主支持的模块形式": "It is not a module form this host supports",
|
||||
"它挂钩的事件": "Events it hooks",
|
||||
"读你的对话": "Reads your conversation",
|
||||
"往模型面前排提示": "Queues prompts in front of the model",
|
||||
"以它自己的名义,不冒充你": "Under its own name, never as you",
|
||||
"来自外部的文字": "Text from outside",
|
||||
"这条调用里的文字,和本次任务从外部读到的内容(网页或工具结果)逐字相同。自动放行、全部放行和记住的答案都不能代你回答;请只批准这一次调用,或拒绝。": "Text in this call is copied word for word from content the task read from outside (a web page or a tool result). Auto, full access and remembered answers cannot answer for you; approve this one call, or decline.",
|
||||
"(没有列出)": "(none listed)",
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
// en_settings and en_kernel were — one screen's worth of wording, read together.
|
||||
|
||||
export const EN_WINDOW: Record<string, string> = {
|
||||
"打开项目": "Open project",
|
||||
"项目列表的更多操作": "More project list actions",
|
||||
"项目与会话": "Projects and conversations",
|
||||
"选择本机文件夹": "Choose a local folder",
|
||||
"连接与机器": "Connections and machines",
|
||||
"打开第一个项目": "Open your first project",
|
||||
"展开所有项目": "Expand all projects",
|
||||
"收起所有项目": "Collapse all projects",
|
||||
"搜索时自动展开项目": "Projects expand automatically while searching",
|
||||
"这台机器的更多操作": "More actions for this machine",
|
||||
"使用系统默认应用打开": "Open with default application",
|
||||
"图片加载失败,它可能已被移动、删除或超过大小限制": "The image could not be loaded. It may have been moved, deleted, or exceed the size limit.",
|
||||
|
||||
@@ -27,6 +27,13 @@ export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_runni
|
||||
export const savedNotApplied = (e: unknown): boolean => e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "");
|
||||
|
||||
const SAID: Record<string, string> = {
|
||||
"agent_composition.invalid": "预设内容无效,请检查标识、模块和参数",
|
||||
"agent_composition.unavailable": "组合所需资源不可用,无法继续执行",
|
||||
"agent_composition.locked": "会话已开始或正在工作,不能切换组合",
|
||||
"agent_composition.workspace_bound": "此会话的组合已绑定原项目,请在目标项目新建会话",
|
||||
"agent_composition.conflict": "预设已被其他操作修改,草稿已保留,请重新加载后再保存",
|
||||
"agent_composition.not_found": "找不到该 Agent 预设",
|
||||
"agent_composition.read_only": "内置预设只读,请复制后编辑",
|
||||
"surface.value_invalid": "填写内容不符合字段要求,请检查后重试",
|
||||
"surface.expired": "界面已失效,请等待新的请求",
|
||||
"surface.schema_invalid": "界面描述不符合约定",
|
||||
@@ -45,6 +52,9 @@ const SAID: Record<string, string> = {
|
||||
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
|
||||
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
|
||||
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
|
||||
"session.archive_invalid": "会话文件无效,请选择完整的原生会话导出文件。",
|
||||
"session.archive_unsupported": "不支持此会话文件版本,请使用兼容版本导出。",
|
||||
"session.archive_too_large": "会话文件超过安全导入限制(大小或记录数)。",
|
||||
"session.title_empty": "请输入聊天标题",
|
||||
"session.title_no_message": "该会话还没有可用于命名的用户消息",
|
||||
"session.title_unavailable": "未配置可用于自动命名的模型,请先配置模型",
|
||||
@@ -67,6 +77,27 @@ const SAID: Record<string, string> = {
|
||||
"workspace.file_invalid": "文件保存请求格式不正确",
|
||||
"workspace.file_changed": "文件已在打开后被其他操作修改,请重新载入",
|
||||
"git.history_failed": "无法读取这个仓库的提交历史",
|
||||
"git.commit_bad_id": "这不是一个提交编号",
|
||||
"git.commit_unknown": "这个仓库里没有这个提交",
|
||||
"git.commit_read_failed": "无法读取这个提交",
|
||||
"discard.bad_request": "丢弃请求的格式不对",
|
||||
"discard.turn_running": "对话正在运行,它可能正在写这个文件,稍后再丢弃",
|
||||
"discard.unknown_path": "这个文件没有未提交的改动",
|
||||
"discard.not_discardable": "只有上次提交里已有的文件,才能丢弃它的改动",
|
||||
"discard.failed": "丢弃失败了",
|
||||
"push.bad_request": "推送请求的格式不对",
|
||||
"push.workspace_untrusted": "这个工作区还没有被你信任,不能从这里推送",
|
||||
"push.detached_head": "现在不在任何分支上,没有可以推送的分支",
|
||||
"push.no_upstream": "这个分支还没有对应的远端分支,不能推送",
|
||||
"push.nothing_to_push": "远端已经有这个分支的全部提交,没有可推送的内容",
|
||||
"push.protocol_not_allowed": "这个远端用的传输方式不允许推送,只支持 https、ssh 和 git://",
|
||||
"push.plan_changed": "确认之后仓库又变了,请重新预览再推送",
|
||||
"push.rejected": "远端有这个分支没有的提交,推送被拒绝(不会强制推送)",
|
||||
"push.auth_required": "远端需要认证,请先在 git 里配置凭据",
|
||||
"push.failed": "推送失败了",
|
||||
"push.git_failed": "推送时 git 出错了",
|
||||
"git.repo_unknown": "这个仓库不属于当前工作区",
|
||||
"git.repos_failed": "无法列出这个工作区里的仓库",
|
||||
"workspace.file_missing": "找不到该文件,它可能已被移动或删除",
|
||||
"workspace.file_failed": "文件操作失败,请重试",
|
||||
"workspace.path_outside_tree": "该路径不在当前工作区内",
|
||||
@@ -142,7 +173,7 @@ const SAID: Record<string, string> = {
|
||||
"model.not_decision_source": "「{model}」是对话模型,不是决策来源;决策只能选择「模型服务」里添加的决策来源",
|
||||
"roles.override_not_in_user_config": "「{key}」不在用户配置里,可能已被清除,或来自项目配置",
|
||||
"roles.search_model_bad_ref": "搜索模型「{model}」的格式应为 服务/模型",
|
||||
"roles.search_model_not_added": "搜索模型「{model}」所在的连接已被移出",
|
||||
"roles.search_model_not_added": "搜索模型「{model}」所在的连接还没有添加:先添加这个连接,再选它",
|
||||
"roles.search_model_removed": "搜索模型「{model}」已不在配置里",
|
||||
"roles.search_model_unsupported": "搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索",
|
||||
"roles.search_model_no_credentials": "搜索模型「{model}」所在的连接没有凭据",
|
||||
@@ -167,6 +198,9 @@ const SAID: Record<string, string> = {
|
||||
"workspace.limit_reached": "项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加",
|
||||
"provider.bad_model_price": "{model} 的价格必须是不小于 0 的数字,并选择货币",
|
||||
"provider.model_prices_unlisted": "{model} 不在已选模型中,未保存价格",
|
||||
"provider.bad_price_source": "{model} 的价格来源必须是 http 或 https 链接,不能含用户名密码或控制字符",
|
||||
"provider.bad_price_checked": "{model} 的核对日期必须是 YYYY-MM-DD,且不晚于今天(按 UTC)",
|
||||
"provider.billing_mode_unknown": "计费方式只能是按量计费或订阅等价估算",
|
||||
"workspace.changing_disabled": "这台服务器不支持切换工作区",
|
||||
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
|
||||
"workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree",
|
||||
@@ -232,6 +266,7 @@ const SAID: Record<string, string> = {
|
||||
"editor.no_window": "这个内核没有窗口,打不开本机的编辑器。",
|
||||
"workspace.not_listed": "这个文件夹不在当前窗口的项目列表里。",
|
||||
"workspace.folder_missing": "项目文件夹已不在磁盘上。",
|
||||
"tool.root_missing": "这次操作需要的文件夹已不在磁盘上:可能被移走、改名或删除了,操作没有执行。",
|
||||
"workspace.locate_no_window": "这个内核不在本机,没法在系统文件管理器中显示它的文件。",
|
||||
"device.host_only": "这项操作只能在电脑上的窗口里做,已配对的手机做不了。",
|
||||
"device.host_rejected": "这个地址不是本机共享的地址,请重新扫码。",
|
||||
@@ -372,11 +407,17 @@ const SAID: Record<string, string> = {
|
||||
"remote.not_available": "该内核不负责连接其他机器",
|
||||
"remote.name_required": "请为该机器填写名称",
|
||||
"remote.host_required": "请填写要连接的地址",
|
||||
"phones.unavailable": "这个窗口没有连到账号服务,不能管理手机",
|
||||
"phones.not_registered": "这台电脑还没有登记到账号:先登录账号并打开远程访问",
|
||||
"phones.not_found": "找不到这台手机,它可能已经被移除",
|
||||
"phones.refused": "账号服务拒绝了这个操作,请刷新后再试",
|
||||
"phones.account_unreachable": "连不上账号服务,请检查网络后重试",
|
||||
"remote.bad_port": "这不是有效的端口号",
|
||||
"remote.distro_invalid": "这不是有效的 WSL 发行版名称",
|
||||
"remote.wsl_unreadable": "读不到已安装的 WSL 发行版",
|
||||
"remote.kind_unsupported": "这台机器的接入方式与表单不同(SSH 或 WSL),不能这样修改它",
|
||||
"remote.disabled": "该主机已停用,启用后才能连接",
|
||||
"remote.unknown_host": "主机 {host} 尚未保存,请先添加该主机再连接",
|
||||
"remote.has_open_panes": "该机器仍有 {n} 个打开的面板,请先关闭再移除",
|
||||
// 主机密钥变了没有「仍然连接」这条路:能绕过的警告等于没有警告。
|
||||
"remote.host_key_changed": "{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。",
|
||||
@@ -437,6 +478,8 @@ const SAID: Record<string, string> = {
|
||||
"history.query_required": "请输入要搜索的内容",
|
||||
"history.search_failed": "对话内容索引暂时无法回答,稍后再试",
|
||||
"job.not_running": "这个后台任务已经不在运行了",
|
||||
"tool.output_unknown": "这个会话里没有这条调用的输出",
|
||||
"tool.output_range": "要取的输出范围不对,请重新展开这条输出",
|
||||
"mux.unknown_connection": "事件流连接已经关闭,需要重新打开并重新订阅各窗格",
|
||||
"mux.pane_not_local": "这个窗格由另一台机器驱动,使用它自己的事件流",
|
||||
"job.stop_in_progress": "已经请求停止这个后台任务,它的进程还没有退出,仍在等待",
|
||||
@@ -450,6 +493,7 @@ const SAID: Record<string, string> = {
|
||||
"inbox.schema_readonly": "该待送达由更高版本写入,当前版本只能读取",
|
||||
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
|
||||
"inbox.foreign_run": "这份待送达属于另一个运行,暂不能接管",
|
||||
"inbox.unrecovered": "待送达的正文读不出来,队列内容未知,已暂停等待检查",
|
||||
|
||||
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
|
||||
"config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
|
||||
@@ -479,6 +523,11 @@ const SAID: Record<string, string> = {
|
||||
"default_mode.editing_disabled": "这台服务器未开放新对话起始模式设置",
|
||||
"default_mode.save_failed": "新对话起始模式设置未能保存:{detail}",
|
||||
"default_mode.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"tools.editing_disabled": "这台服务器未开放工具设置",
|
||||
"tools.save_failed": "工具设置未能保存:{detail}",
|
||||
"tools.no_choice": "本次请求未说明要改哪个工具,未做任何修改",
|
||||
"tools.host_required": "这个工具被其他功能需要,不能关闭",
|
||||
"tool.disabled_by_config": "这个工具已在配置里关闭,本次调用未执行",
|
||||
"peer.disabled": "会话间消息未开启",
|
||||
"peer.editing_disabled": "这个客户端不能收发会话间消息",
|
||||
"peer.not_found": "本机没有叫这个名字的会话",
|
||||
@@ -506,11 +555,13 @@ const SAID: Record<string, string> = {
|
||||
"peer.settings_unwritable": "会话间消息的设置没能保存:{detail}",
|
||||
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
|
||||
"write_lease.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"write_lease.bad_wait": "最长等待的分钟数不能是负数",
|
||||
"write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
|
||||
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
|
||||
"display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
|
||||
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
|
||||
"changed_files.save_failed": "文件改动显示设置未能保存:{detail}",
|
||||
"feedback_level_notices.save_failed": "等级提示的设置未能保存:{detail}",
|
||||
"fold_work.save_failed": "折叠中间过程的设置未能保存:{detail}",
|
||||
"turn_narration.save_failed": "回合间叙述的设置未能保存:{detail}",
|
||||
"web_access.editing_disabled": "这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。",
|
||||
@@ -584,7 +635,8 @@ const SAID: Record<string, string> = {
|
||||
"market.bad_response": "社区市场返回了无法识别的内容",
|
||||
"market.filter_unsupported": "社区市场暂不支持只列出已固定内容的包",
|
||||
"market.unpinned": "该包的审核版本没有固定内容,需要信任发布者后才能安装",
|
||||
"market.bad_source": "该包的审核版本指向本机或不安全的来源,不从市场安装",
|
||||
"market.bad_source": "该包的来源地址无效或不受支持,无法从市场安装;请发布者核对来源后重新提交",
|
||||
"market.invalid_plugin_source": "插件或主题的来源地址无效:必须使用 HTTPS GitHub 目录地址,并固定到完整的 40 位小写十六进制提交号(tree/提交号);请核对地址,修正后重新提交",
|
||||
"market.version_changed": "审核版本已更新,请重新查看后再安装",
|
||||
"market.content_changed": "来源内容已与审核版本不同,已拒绝安装",
|
||||
"market.not_pinnable": "该来源的内容无法固定,已拒绝安装",
|
||||
@@ -642,6 +694,9 @@ const SAID: Record<string, string> = {
|
||||
"wallet.unauthorized": "该供应商拒绝了当前密钥,无法读取余额",
|
||||
"wallet.unreachable": "该供应商的余额接口无响应",
|
||||
"wallet.unreadable": "无法解析该供应商余额接口返回的内容",
|
||||
"wallet.format_not_declared": "余额格式未配置:这个地址不是供应商自己的域名,请在连接设置里选择它返回的格式",
|
||||
"provider.balance_url_invalid": "余额地址必须是 http 或 https 开头的网址",
|
||||
"provider.balance_format_unknown": "这个余额格式不在可选范围内",
|
||||
"feedback.invalid": "反馈内容不符合要求,请检查后重试",
|
||||
"feedback.too_large": "反馈内容过大,请缩短文字或减少截图",
|
||||
"feedback.rate_limited": "提交太频繁了,请稍后再试",
|
||||
@@ -662,6 +717,11 @@ const SAID: Record<string, string> = {
|
||||
"feedback.link_conflict": "这台电脑已关联到另一个账号",
|
||||
"feedback.link_limit": "这个账号关联的电脑已达上限",
|
||||
"feedback.no_install": "这台电脑还没有发过反馈",
|
||||
"feedback.board_name_invalid": "名字需要 2 到 30 个字符:字母、数字、空格、下划线或连字符,不能含 @ 或网址",
|
||||
"feedback.board_name_reserved": "这个名字是保留的,会让人误以为是维护者,换一个吧",
|
||||
"feedback.board_name_taken": "已经有人用这个名字了",
|
||||
"feedback.board_delisted": "这个账号已被维护者从榜单移除,暂时不能再加入",
|
||||
"feedback.board_no_link": "先把这台电脑关联到账号,才能加入榜单",
|
||||
|
||||
// ── 远程连接停下来问的那一句 ───────────────────────────────────
|
||||
"ask.not_found": "不存在该待回答的问题",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { reason } from "./i18n/kernel";
|
||||
import { track as trackWidth } from "./ui/viewport";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import "./styles/composition-picker.css";
|
||||
import "./styles/dock.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/dialogcenter.css";
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface SubagentSteerSettings {
|
||||
// opened after it.
|
||||
export interface WriteLeaseSettings {
|
||||
optimistic: boolean;
|
||||
maxWaitMinutes: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
@@ -143,6 +144,23 @@ export interface DefaultModeSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// One tool on the settings page: whether the user removed it, and which host
|
||||
// feature needs it when it cannot be removed.
|
||||
export interface ToolSetting {
|
||||
name: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
requiredBy?: string;
|
||||
byProject?: boolean;
|
||||
}
|
||||
|
||||
// The tools a request carries plus the ones the user removed, as the user file
|
||||
// records them.
|
||||
export interface ToolSettings {
|
||||
tools: ToolSetting[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PeerView {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -315,6 +333,13 @@ export interface ObservabilitySettings {
|
||||
|
||||
export type ObservabilityPatch = Partial<Pick<ObservabilitySettings, "enabled" | "endpoint" | "headerName" | "headerEnv" | "serviceName">>;
|
||||
|
||||
// Whether the person is told when their feedback level goes up; the user file
|
||||
// holds it, and it starts off.
|
||||
export interface FeedbackLevelNotices {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ChangedFilesSettings {
|
||||
show: boolean;
|
||||
path: string;
|
||||
|
||||
@@ -400,3 +400,62 @@ describe("a request too large for one message", () => {
|
||||
expect(again[0].id).toBe(partsOf(first.socket)[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a slow answer over a live link", () => {
|
||||
function chunk(socket: FakeSocket, id: string, index: number, done: boolean) {
|
||||
socket.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({
|
||||
type: "desktop.response", id, index, done, status: 200,
|
||||
body: linkCodec.bytesToBase64(new TextEncoder().encode(`part${index};`)),
|
||||
}) }));
|
||||
}
|
||||
|
||||
it("is not failed while its own chunks keep arriving, however long the whole takes", async () => {
|
||||
const first = connection();
|
||||
const link = new RemoteLink(first.conn, vi.fn(async () => first.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
|
||||
const answer = link.fetch(request());
|
||||
const settled = answer.then(() => "answered", (error: Error) => error.message);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const id = String(first.socket.sent[0]?.id);
|
||||
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
chunk(first.socket, id, index, index === 4);
|
||||
}
|
||||
|
||||
await expect(settled).resolves.toBe("answered");
|
||||
await expect((await answer).text()).resolves.toBe("part0;part1;part2;part3;part4;");
|
||||
});
|
||||
|
||||
it("keeps waiting for a request queued behind others while the desktop is still answering them", async () => {
|
||||
const first = connection();
|
||||
const link = new RemoteLink(first.conn, vi.fn(async () => first.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
|
||||
const queued = link.fetch(request("GET", "/rt/two/events/replay"));
|
||||
const settled = queued.then(() => "answered", (error: Error) => error.message);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const id = String(first.socket.sent[0]?.id);
|
||||
|
||||
for (let beat = 0; beat < 6; beat++) {
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
chunk(first.socket, "someone-elses-request", beat, false);
|
||||
}
|
||||
first.socket.reply(id, "finally");
|
||||
|
||||
await expect(settled).resolves.toBe("answered");
|
||||
});
|
||||
|
||||
it("still gives up on a request nothing has answered, so a silent desktop is not waited on forever", async () => {
|
||||
const first = connection();
|
||||
const second = connection();
|
||||
const link = new RemoteLink(first.conn, vi.fn(async () => second.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
|
||||
const answer = link.fetch(request());
|
||||
const settled = answer.then(() => "answered", (error: Error) => error.message);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
for (let beat = 0; beat < 40; beat++) {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
chunk(first.socket, "someone-elses-request", beat, false);
|
||||
}
|
||||
|
||||
await expect(settled).resolves.toMatch(/Remote Studio|远程 Studio/);
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,8 @@ interface Pending {
|
||||
contentType?: string;
|
||||
etag?: string;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
// When this request last made progress: sent whole, or one more chunk of its answer.
|
||||
progressAt?: number;
|
||||
resolve: (value: Response) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
}
|
||||
@@ -73,6 +75,11 @@ export interface RemoteLinkOptions {
|
||||
|
||||
const MAX_RETRY_AFTER_MS = 2 * 60 * 1000;
|
||||
|
||||
// The desktop answers one request at a time, so a request can wait behind others
|
||||
// it knows nothing about. While the link is delivering their answers it is waited
|
||||
// for, up to this long without a word of its own.
|
||||
const MAX_QUEUED_MS = 5 * 60 * 1000;
|
||||
|
||||
// The desktop reads at most 64 KiB from the relay in one message and closes the
|
||||
// link on a larger one, taking every other request on it down too. A request
|
||||
// that would seal past this is refused here instead of being sent. The budget
|
||||
@@ -250,6 +257,12 @@ export class RemoteLink {
|
||||
while (connection.socket.bufferedAmount > BUFFER_HIGH && live()) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
if (!live()) return;
|
||||
}
|
||||
this.arm(entry);
|
||||
}
|
||||
|
||||
private arm(entry: Pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.progressAt = Date.now();
|
||||
entry.timer = setTimeout(() => this.timedOut(entry), this.options.requestTimeoutMs);
|
||||
}
|
||||
|
||||
@@ -263,6 +276,10 @@ export class RemoteLink {
|
||||
try { stale.socket.close(CLOSE_RESYNC, "Resync"); } catch { /* already closing */ }
|
||||
return;
|
||||
}
|
||||
if (Date.now() - (entry.progressAt ?? 0) < MAX_QUEUED_MS) {
|
||||
entry.timer = setTimeout(() => this.timedOut(entry), this.options.requestTimeoutMs);
|
||||
return;
|
||||
}
|
||||
this.pending.delete(entry.id);
|
||||
entry.reject(new Error(t("远程 Studio 暂无响应,请检查电脑是否在线后重试。")));
|
||||
}
|
||||
@@ -367,7 +384,10 @@ export class RemoteLink {
|
||||
pending.contentType = typeof message.contentType === "string" ? message.contentType : pending.contentType;
|
||||
pending.etag = typeof message.etag === "string" ? message.etag : pending.etag;
|
||||
pending.chunks.set(message.index, message.body);
|
||||
if (message.done !== true) return;
|
||||
if (message.done !== true) {
|
||||
this.arm(pending);
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
const headers = new Headers();
|
||||
|
||||
@@ -6,6 +6,9 @@ import { classifyHandshakeFailure, failureView, retryAfterSeconds } from "./clou
|
||||
import { linkCodec, RemoteLink, RemoteLinkError, type RemoteConnection, type RemoteEnd } from "./cloud_link";
|
||||
|
||||
const ACCOUNT = (import.meta.env.VITE_ACCOUNTS_API || "https://id.reasonix.io").replace(/\/$/, "");
|
||||
export const accountOrigin = ACCOUNT;
|
||||
// The computer this page is controlling; null on the desktop's own window.
|
||||
export const remoteDeviceId = () => new URLSearchParams(location.search).get("device")?.trim() || null;
|
||||
const RELAY = (import.meta.env.VITE_REMOTE_GATEWAY || "wss://remote.reasonix.io").replace(/\/$/, "");
|
||||
const REMOTE_HOME = import.meta.env.VITE_REMOTE_HOME || "https://reasonix.io/remote/";
|
||||
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import definitions from "../../../../internal/contract/agentcomposition/builtins.json";
|
||||
|
||||
export type CompositionKind = "tools" | "skills" | "mcp" | "extensions" | "hooks" | "commands";
|
||||
export interface CompositionModule { id: string; parameters?: Record<string, unknown> }
|
||||
export interface CompositionSelection { mode: "inherit" | "select"; members: CompositionModule[] }
|
||||
export interface AgentComposition {
|
||||
schemaVersion: number;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
persona: { prefix: string; suffix: string; include_runtime_context: boolean };
|
||||
tools: CompositionSelection;
|
||||
skills: CompositionSelection;
|
||||
mcp: CompositionSelection;
|
||||
extensions: CompositionSelection;
|
||||
hooks: CompositionSelection;
|
||||
commands: CompositionSelection;
|
||||
}
|
||||
export interface AgentPresetVersion { definition: AgentComposition; revision: string }
|
||||
export interface AgentPresetCatalog {
|
||||
presets: AgentPresetVersion[];
|
||||
default: string;
|
||||
diagnostics: { code: string; module?: string; detail: string }[];
|
||||
modules?: Partial<Record<CompositionKind,CompositionModule[]>>;
|
||||
}
|
||||
export interface AgentPresetImport {definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}
|
||||
export interface CompositionSummary { id: string; name: string; revision: string; digest: string; started: boolean; unavailable?: string }
|
||||
export const COMPOSITION_KINDS: CompositionKind[] = ["tools", "skills", "mcp", "extensions", "hooks", "commands"];
|
||||
export const builtinCompositions = (): AgentComposition[] => structuredClone(definitions) as AgentComposition[];
|
||||
@@ -33,6 +33,16 @@ export const FEEDBACK_WITHDRAWABLE: readonly FeedbackStatus[] = ["received", "ne
|
||||
export const canWithdraw = (item: Pick<FeedbackItem, "status" | "statusUnavailable">): boolean =>
|
||||
!item.statusUnavailable && FEEDBACK_WITHDRAWABLE.includes(item.status);
|
||||
|
||||
// One process's memory as a report carries it: a type from a fixed list and two
|
||||
// sizes in kilobytes. Nothing in it names a path, a process id or a user.
|
||||
export type FeedbackProcessType = "browser" | "renderer" | "gpu" | "utility" | "kernel";
|
||||
|
||||
export interface FeedbackProcessMemory {
|
||||
process: FeedbackProcessType;
|
||||
workingSetKB: number;
|
||||
privateKB: number;
|
||||
}
|
||||
|
||||
export interface FeedbackEnvInfo {
|
||||
version: string;
|
||||
commit: string;
|
||||
@@ -43,6 +53,8 @@ export interface FeedbackEnvInfo {
|
||||
locale: string;
|
||||
channel: string;
|
||||
providerKind: string;
|
||||
// The kernel's own row; the window's shell adds the rest when a report is sent.
|
||||
memory?: FeedbackProcessMemory[];
|
||||
}
|
||||
|
||||
// uploadBytes bounds one raw file the page may hand over; the kernel shrinks
|
||||
@@ -77,6 +89,8 @@ export interface FeedbackRequest {
|
||||
// The UI language tag, so the issue can be triaged in the reporter's language.
|
||||
locale?: string;
|
||||
images: FeedbackImage[];
|
||||
// The shell's process rows; the kernel keeps the fixed types and adds its own.
|
||||
memory?: FeedbackProcessMemory[];
|
||||
// Answer to a feedback.challenge_required refusal.
|
||||
turnstileToken?: string;
|
||||
}
|
||||
@@ -170,6 +184,13 @@ export interface FeedbackMine {
|
||||
unread: number;
|
||||
hasNew: boolean;
|
||||
profile?: FeedbackProfile | null;
|
||||
// A level the service now states that this machine has not told the person about.
|
||||
promotion?: FeedbackPromotion | null;
|
||||
}
|
||||
|
||||
export interface FeedbackPromotion {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
// One install an account holds, as the service lists it: the short id, when it
|
||||
@@ -193,6 +214,37 @@ export const FEEDBACK_LIMIT = {
|
||||
globalBurst: "global_burst",
|
||||
} as const;
|
||||
|
||||
// One person on the contribution board: the name they chose, how many of their
|
||||
// suggestions shipped, where, and (in a release view only) their place.
|
||||
export interface FeedbackBoardEntry {
|
||||
displayName: string;
|
||||
credits: number;
|
||||
rank: number | null;
|
||||
releases: FeedbackBoardRelease[];
|
||||
}
|
||||
|
||||
export interface FeedbackBoardRelease {
|
||||
version: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// The public board. available is false where the service does not serve one, which
|
||||
// hides the section rather than failing it.
|
||||
export interface FeedbackBoard {
|
||||
available: boolean;
|
||||
view: "all" | "release";
|
||||
release: string | null;
|
||||
entries: FeedbackBoardEntry[];
|
||||
}
|
||||
|
||||
// What the signed-in person has chosen about being on the board.
|
||||
export interface FeedbackBoardMe {
|
||||
listed: boolean;
|
||||
displayName: string | null;
|
||||
// A maintainer removed this account; it cannot list itself again until restored.
|
||||
delisted: boolean;
|
||||
}
|
||||
|
||||
export const FEEDBACK_REPO_ISSUES = "https://github.com/esengine/DeepSeek-Reasonix/issues/";
|
||||
|
||||
export const FEEDBACK_NEXT_VERSION = "next";
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// the app learns which shell it is running in. Electron exposes a preload
|
||||
// bridge; a browser tab has none, and answers for itself.
|
||||
|
||||
import type { FeedbackProcessMemory } from "./feedback";
|
||||
|
||||
export type Shell = "electron" | "browser";
|
||||
|
||||
export interface HostInfo {
|
||||
@@ -38,6 +40,9 @@ export interface HostPort {
|
||||
captureWindow(): Promise<Uint8Array | null>;
|
||||
/** How the shell draws this launch; null where it has no say, as in a tab. */
|
||||
graphics(): Promise<GraphicsInfo | null>;
|
||||
/** The shell's own processes as a type and two sizes each, for the memory a
|
||||
* report may carry; empty where there is no shell to ask, as in a tab. */
|
||||
processMemory(): Promise<FeedbackProcessMemory[]>;
|
||||
openExternal(url: string): void | Promise<void>;
|
||||
setUnreadBadge(unread: boolean): Promise<void>;
|
||||
/** Where dropped files live. Empty where the shell cannot say — a browser
|
||||
@@ -162,6 +167,7 @@ interface ElectronBridge {
|
||||
closeWindow(): Promise<void>;
|
||||
captureWindow?(): Promise<Uint8Array | null>;
|
||||
graphics?(): Promise<GraphicsInfo | null>;
|
||||
processMemory?(): Promise<unknown>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
setUnreadBadge?(unread: boolean): Promise<void>;
|
||||
pathForFile(file: File): string;
|
||||
@@ -226,6 +232,9 @@ class ElectronHost implements HostPort {
|
||||
graphics() {
|
||||
return this.api.graphics?.().catch(() => null) ?? Promise.resolve(null);
|
||||
}
|
||||
processMemory() {
|
||||
return this.api.processMemory?.().then((rows: unknown) => (Array.isArray(rows) ? (rows as FeedbackProcessMemory[]) : [])).catch(() => []) ?? Promise.resolve([]);
|
||||
}
|
||||
openExternal(url: string) {
|
||||
return this.api.openExternal(url);
|
||||
}
|
||||
@@ -328,6 +337,9 @@ class BrowserHost implements HostPort {
|
||||
graphics() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
processMemory() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
openExternal(url: string) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,18 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { SseHub } from "./hub";
|
||||
import { HttpError } from "./port";
|
||||
|
||||
it("uploads native archives as opaque bodies to the owning host route", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ path: "imported" }) });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
try {
|
||||
const file = new File(["{ not parsed here"], "session.json");
|
||||
await new SseHub().importNativeSession(file, "/folder with space");
|
||||
expect(fetch).toHaveBeenCalledWith("/tree/sessions/import-native?workspace=%2Ffolder+with+space",
|
||||
expect.objectContaining({ method: "POST", body: file,
|
||||
headers: { "content-type": "application/json" }, credentials: "same-origin" }));
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
function answer(status: number, body: string) {
|
||||
vi.stubGlobal("fetch", async () => ({
|
||||
ok: false,
|
||||
@@ -13,11 +25,36 @@ function answer(status: number, body: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
it("keeps local project moves and host-qualified pin/archive calls on their owning routes", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
try {
|
||||
const hub = new SseHub();
|
||||
await hub.pinSession("/same/session.jsonl", true);
|
||||
await hub.pinSession("/same/session.jsonl", false, "gpu box");
|
||||
await hub.archiveSession("/same/session.jsonl", true);
|
||||
await hub.archiveSession("/same/session.jsonl", false, "gpu box");
|
||||
await hub.moveSessionTo("/same/session.jsonl", "/other-project");
|
||||
expect(fetch.mock.calls.map(([path, init]) => [path, JSON.parse(init.body)])).toEqual([
|
||||
["/tree/sessions/pin", { path: "/same/session.jsonl", pinned: true }],
|
||||
["/remotes/gpu%20box/sessions/pin", { path: "/same/session.jsonl", pinned: false }],
|
||||
["/tree/sessions/archive", { path: "/same/session.jsonl", archived: true }],
|
||||
["/remotes/gpu%20box/sessions/archive", { path: "/same/session.jsonl", archived: false }],
|
||||
["/tree/sessions/move-to", { path: "/same/session.jsonl", target: "/other-project" }],
|
||||
]);
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
// The same defect SseHttp was fixed for, still standing in the hub port: asking
|
||||
// for JSON first threw away every answer that was not an envelope, and what
|
||||
// reached the window was "this request did not reach the kernel (HTTP 502)" —
|
||||
// wrong twice over, because it had, and the kernel had explained itself.
|
||||
describe("a failed hub call", () => {
|
||||
it("surfaces a remote book conflict instead of treating it as an unread book", async () => {
|
||||
answer(409, JSON.stringify({ code: "remote.serve_provider_mismatch", error: "provider setting conflict", params: { host: "gpu-box" } }));
|
||||
await expect(new SseHub().remoteTree("gpu-box")).rejects.toMatchObject({ status: 409,
|
||||
reason: { code: "remote.serve_provider_mismatch", params: { host: "gpu-box" } } });
|
||||
});
|
||||
it("carries a plain-text reason through", async () => {
|
||||
answer(502, "remote /runtimes: 400 Bad Request: workspace /srv/data is not a directory");
|
||||
const err = (await new SseHub().openRemote({ host: "gpu-box" }).catch((e) => e)) as HttpError;
|
||||
@@ -59,6 +96,20 @@ function jsonAnswer(status: number, body: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
it("serializes the remote composition choice at the HTTP request boundary", async () => {
|
||||
const fetch = vi.fn(async () => jsonAnswer(200, { id: "remote", root: "/project", host: "gpu", base: "/runtime/remote", name: "Remote" }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
try {
|
||||
const hub = new SseHub();
|
||||
for (const agentComposition of ["builtin:minimal", "builtin:general", undefined]) {
|
||||
await hub.openRemote({ host: "gpu", workspace: "/project", agentComposition });
|
||||
const [url, init] = fetch.mock.lastCall as unknown as [string, RequestInit];
|
||||
expect(url).toBe("/remotes/open");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ host: "gpu", workspace: "/project", sessionPath: "", ...(agentComposition ? { agentComposition } : {}) });
|
||||
}
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
describe("host capabilities", () => {
|
||||
it("does not ask a kernel picker the capability probe says is absent", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { watchTree } from "./tree_events";
|
||||
import { HttpError, type AgentPort } from "./port";
|
||||
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe, WSLDistros } from "./remote";
|
||||
import type { CloudShareOffer, DeviceSelf, ShareOffer, SharePort, ShareStatus } from "./share";
|
||||
import type { CloudShareOffer, DeviceSelf, PhoneAction, PhoneList, ShareOffer, SharePort, ShareStatus } from "./share";
|
||||
import { SsePort } from "./sse";
|
||||
import { host } from "./host";
|
||||
|
||||
@@ -19,6 +19,7 @@ function operationHeaders(operationId?: string): Record<string, string> {
|
||||
// RuntimeView is one open pane. Base is the prefix every request of that pane
|
||||
// carries, which is also what tells the shell's bus which channel to listen on.
|
||||
export interface RuntimeView {
|
||||
agentComposition?: import("./composition").CompositionSummary;
|
||||
id: string;
|
||||
base: string;
|
||||
root: string;
|
||||
@@ -161,7 +162,7 @@ export interface HistoryHit {
|
||||
|
||||
export interface HubPort extends SharePort {
|
||||
runtimes(): Promise<RuntimeView[]>;
|
||||
open(req: { root?: string; sessionPath?: string }): Promise<RuntimeView>;
|
||||
open(req: { root?: string; sessionPath?: string; agentComposition?: string }): Promise<RuntimeView>;
|
||||
close(id: string): Promise<void>;
|
||||
tree(window?: TreeWindow): Promise<TreeWorkspace[]>;
|
||||
/** Conversations, in every workspace, that say these words. */
|
||||
@@ -183,9 +184,9 @@ export interface HubPort extends SharePort {
|
||||
removeSession(path: string): Promise<void>;
|
||||
/** The conversation moves into the named project; originalRemoved is false when the copy arrived but the original could not be taken away. */
|
||||
moveSessionTo(path: string, target: string): Promise<{ path: string; originalRemoved: boolean }>;
|
||||
archiveSession(path: string, archived: boolean): Promise<void>;
|
||||
archiveSession(path: string, archived: boolean, host?: string): Promise<void>;
|
||||
// Tells the kernel which conversations automatic archiving must leave alone.
|
||||
pinSession(path: string, pinned: boolean): Promise<void>;
|
||||
pinSession(path: string, pinned: boolean, host?: string): Promise<void>;
|
||||
// Adds pins made before the kernel kept them. The first call also opens the
|
||||
// gate: until then the kernel archives nothing on its own.
|
||||
syncPins(paths: string[]): Promise<void>;
|
||||
@@ -193,6 +194,7 @@ export interface HubPort extends SharePort {
|
||||
autoNameSession(path: string): Promise<{ title: string }>;
|
||||
exportSession(path: string): Promise<{ name: string; content: string }>;
|
||||
importLegacySessions(path: string, workspace: string): Promise<LegacyImport>;
|
||||
importNativeSession(file: Blob, workspace: string): Promise<{ path: string }>;
|
||||
// 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
|
||||
@@ -200,7 +202,7 @@ export interface HubPort extends SharePort {
|
||||
remoteHosts(): Promise<RemoteHost[] | null>;
|
||||
// Opens a pane on another machine. Slow the first time — the kernel may be
|
||||
// installing itself over there — so the caller polls remoteHosts for the step.
|
||||
openRemote(req: { host: string; workspace?: string; sessionPath?: string }): Promise<RuntimeView>;
|
||||
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }): Promise<RuntimeView>;
|
||||
// The far machine's own workspaces, or null while nothing is open on it:
|
||||
// there is no kernel over there to ask until a pane puts one there.
|
||||
remoteTree(host: string): Promise<TreeWorkspace[] | null>;
|
||||
@@ -315,8 +317,8 @@ export class SseHub implements HubPort {
|
||||
return (await res.json()) as RuntimeView[];
|
||||
}
|
||||
|
||||
open(req: { root?: string; sessionPath?: string }) {
|
||||
return this.post<RuntimeView>("/runtimes", { root: req.root ?? "", sessionPath: req.sessionPath ?? "" });
|
||||
open(req: { root?: string; sessionPath?: string; agentComposition?: string }) {
|
||||
return this.post<RuntimeView>("/runtimes", { root: req.root ?? "", sessionPath: req.sessionPath ?? "", agentComposition: req.agentComposition });
|
||||
}
|
||||
|
||||
async close(id: string) {
|
||||
@@ -388,12 +390,12 @@ export class SseHub implements HubPort {
|
||||
await this.post<void>("/tree/sessions/remove", { path });
|
||||
}
|
||||
|
||||
async archiveSession(path: string, archived: boolean) {
|
||||
await this.post<void>("/tree/sessions/archive", { path, archived });
|
||||
async archiveSession(path: string, archived: boolean, host?: string) {
|
||||
await this.post<void>(host ? `/remotes/${encodeURIComponent(host)}/sessions/archive` : "/tree/sessions/archive", { path, archived });
|
||||
}
|
||||
|
||||
async pinSession(path: string, pinned: boolean) {
|
||||
await this.post<void>("/tree/sessions/pin", { path, pinned });
|
||||
async pinSession(path: string, pinned: boolean, host?: string) {
|
||||
await this.post<void>(host ? `/remotes/${encodeURIComponent(host)}/sessions/pin` : "/tree/sessions/pin", { path, pinned });
|
||||
}
|
||||
|
||||
async syncPins(paths: string[]) {
|
||||
@@ -416,6 +418,16 @@ export class SseHub implements HubPort {
|
||||
return this.post<LegacyImport>("/tree/sessions/import-legacy", { path, workspace });
|
||||
}
|
||||
|
||||
async importNativeSession(file: Blob, workspace: string): Promise<{ path: string }> {
|
||||
const path = `/tree/sessions/import-native?${new URLSearchParams({ workspace })}`;
|
||||
const response = await fetch(path, {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" }, body: file,
|
||||
});
|
||||
if (!response.ok) await SseHub.fail(path, response);
|
||||
return await response.json() as { path: string };
|
||||
}
|
||||
|
||||
async remoteHosts() {
|
||||
const res = await fetch("/remotes", { credentials: "same-origin" });
|
||||
// Not an error to report: this kernel does not do remote panes, or answers
|
||||
@@ -425,11 +437,11 @@ export class SseHub implements HubPort {
|
||||
return (await res.json()) as RemoteHost[];
|
||||
}
|
||||
|
||||
openRemote(req: { host: string; workspace?: string; sessionPath?: string }) {
|
||||
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }) {
|
||||
return this.dial((operationId) =>
|
||||
this.post<RuntimeView>(
|
||||
"/remotes/open",
|
||||
{ host: req.host, workspace: req.workspace ?? "", sessionPath: req.sessionPath ?? "" },
|
||||
{ host: req.host, workspace: req.workspace ?? "", sessionPath: req.sessionPath ?? "", agentComposition: req.agentComposition },
|
||||
operationId,
|
||||
),
|
||||
);
|
||||
@@ -442,9 +454,6 @@ export class SseHub implements HubPort {
|
||||
async remoteTree(host: string) {
|
||||
const path = `/remotes/${encodeURIComponent(host)}/tree`;
|
||||
const res = await fetch(path, { credentials: "same-origin" });
|
||||
// Nothing open on that machine yet, which is a step to take rather than a
|
||||
// failure to report.
|
||||
if (res.status === 409) return null;
|
||||
if (!res.ok) await SseHub.fail(path, res);
|
||||
return (await res.json()) as TreeWorkspace[];
|
||||
}
|
||||
@@ -626,6 +635,21 @@ export class SseHub implements HubPort {
|
||||
return this.post<ShareStatus>("/share/revoke", { id });
|
||||
}
|
||||
|
||||
async sharePhones() {
|
||||
const res = await fetch("/share/phones", { credentials: "same-origin" });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) await SseHub.fail("/share/phones", res);
|
||||
return (await res.json()) as PhoneList;
|
||||
}
|
||||
|
||||
resolvePhone(id: string, action: PhoneAction, replaceId?: string) {
|
||||
return this.post<PhoneList>("/share/phones/resolve", { id, action, ...(replaceId ? { replaceId } : {}) });
|
||||
}
|
||||
|
||||
revokePhone(id: string) {
|
||||
return this.post<PhoneList>("/share/phones/revoke", { id });
|
||||
}
|
||||
|
||||
async device() {
|
||||
const res = await fetch("/device", { credentials: "same-origin" });
|
||||
if (res.status === 404) return null;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations }
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
|
||||
import { mockStorage, mockStoragePlan } from "./mock_storage";
|
||||
import { MEMORIES } from "./mock_memory";
|
||||
import { MEMORIES, mockMemoryCatalog } from "./mock_memory";
|
||||
import { mockModels } from "./mock_models";
|
||||
import { mockUsage } from "./mock_usage";
|
||||
|
||||
@@ -117,11 +117,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
}
|
||||
|
||||
async memories(): Promise<MemoryCatalog> {
|
||||
return {
|
||||
memories: this.mem.map((m) => ({ ...m })),
|
||||
recallQuery: "缓存命中率为什么会掉",
|
||||
indexPath: "~/.reasonix/projects/reasonix/memory/MEMORY.md",
|
||||
};
|
||||
return mockMemoryCatalog(this.mem);
|
||||
}
|
||||
|
||||
// The fixture reports a clean revert on the first ask and a conflicted one
|
||||
@@ -359,8 +355,8 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
|
||||
async markSessionViewed() {}
|
||||
|
||||
async status() {
|
||||
return { ...this.state };
|
||||
async status(): Promise<SessionStatus> {
|
||||
return { ...this.state, agentCompositionLocked: this.compositions.compositionStarted, ...(location.search.includes("tariff") && { tariff: { peak: !location.search.includes("tariff=off"), changesAt: new Date(Date.now() + 130 * 60_000).toISOString() } }) };
|
||||
}
|
||||
|
||||
async balance(): Promise<WalletReading | null> {
|
||||
@@ -575,7 +571,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
|
||||
// The fixture has a window with an icon, because the state worth designing
|
||||
// is the one where both switches mean something.
|
||||
private notifications: NotifyPrefs = { enabled: false, turnDone: true, approval: true, ask: true, feedbackReply: true, diskLow: true, sound: false };
|
||||
private notifications: NotifyPrefs = { enabled: false, turnDone: true, approval: true, ask: true, feedbackReply: true, diskLow: true, sound: false, phone: { enabled: false, approval: false, input: false, error: false } };
|
||||
|
||||
async notifyPrefs(): Promise<NotifyPrefs | null> {
|
||||
return { ...this.notifications };
|
||||
@@ -694,6 +690,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
}
|
||||
|
||||
async submit(text: string, chips?: ChipCall): Promise<Queued | void> {
|
||||
this.compositions.markCompositionStarted();
|
||||
if (chips && this.queuePaused) return this.queueFollowup(text);
|
||||
if (this.state.running) {
|
||||
this.emit({ kind: "steer", text });
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
|
||||
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
|
||||
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import { mockMods } from "./mock_mods";
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockShell } from "./mock_shell";
|
||||
import { MockCompositions } from "./mock_composition";
|
||||
import type { AgentComposition } from "./composition";
|
||||
import type { PersonaDraft, PersonaSettings } from "./boundary";
|
||||
|
||||
// The boundary half of the fixture: what the agent is refused outright, and how
|
||||
@@ -17,7 +19,20 @@ function effectiveBash(s: SandboxSettings): string {
|
||||
return s.bash.trim() === "off" ? "off" : "enforce";
|
||||
}
|
||||
|
||||
// One file per machine, so every port of the demo reads the same answer.
|
||||
let levelNotices: FeedbackLevelNotices = { enabled: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
export class MockBoundary extends MockShell {
|
||||
protected compositions = new MockCompositions();
|
||||
shareAgentPresetsFrom(other: MockBoundary, workspace:string) { this.compositions=other.compositions.fork(workspace); }
|
||||
agentPresets() { return this.compositions.agentPresets(); }
|
||||
saveAgentPreset(d: AgentComposition, expected: string) { return this.compositions.saveAgentPreset(d, expected); }
|
||||
deleteAgentPreset(id: string, revision: string) { return this.compositions.deleteAgentPreset(id, revision); }
|
||||
setDefaultAgentPreset(id: string, workspace: boolean) { return this.compositions.setDefaultAgentPreset(id, workspace); }
|
||||
exportAgentPreset(id: string) { return this.compositions.exportAgentPreset(id); }
|
||||
importAgentPreset(d: AgentComposition) { return this.compositions.importAgentPreset(d); }
|
||||
agentComposition() { return this.compositions.agentComposition(); }
|
||||
selectAgentComposition(id: string) { return this.compositions.selectAgentComposition(id); }
|
||||
async decideWorkspaceTrust(_trust: "trusted" | "declined") {}
|
||||
// One interrupted barrier and one that a later turn took over, so the card
|
||||
// and the reason it disappears are both reachable without a crash.
|
||||
@@ -185,14 +200,37 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.modes };
|
||||
}
|
||||
|
||||
private lease: WriteLeaseSettings = { optimistic: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
private toolList: ToolSettings = {
|
||||
tools: [
|
||||
{ name: "bash", description: "Run a shell command in the workspace.", disabled: false },
|
||||
{ name: "read_file", description: "Read a file, or a line range of it.", disabled: false },
|
||||
{ name: "recall", description: "Search what earlier turns folded away.", disabled: false },
|
||||
{ name: "todo_write", description: "Keep a checklist for a multi-step task.", disabled: false, requiredBy: "plan_steps" },
|
||||
{ name: "web_search", description: "Search the web.", disabled: true, byProject: true },
|
||||
{ name: "update_goal", description: "Report a goal as complete, blocked or stopped.", disabled: false, requiredBy: "goal_mode" },
|
||||
{ name: "use_capability", description: "Reach a deferred MCP tool.", disabled: false, requiredBy: "deferred_mcp_tools" },
|
||||
{ name: "web_fetch", description: "Fetch a web page.", disabled: false },
|
||||
],
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
};
|
||||
|
||||
async toolSettings(): Promise<ToolSettings> {
|
||||
return structuredClone(this.toolList);
|
||||
}
|
||||
|
||||
async saveToolDisabled(name: string, disabled: boolean): Promise<ToolSettings> {
|
||||
this.toolList = { ...this.toolList, tools: this.toolList.tools.map((x) => (x.name === name ? { ...x, disabled } : x)) };
|
||||
return structuredClone(this.toolList);
|
||||
}
|
||||
|
||||
private lease: WriteLeaseSettings = { optimistic: false, maxWaitMinutes: 0, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async writeLease(): Promise<WriteLeaseSettings> {
|
||||
return { ...this.lease };
|
||||
}
|
||||
|
||||
async saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings> {
|
||||
this.lease = { ...this.lease, optimistic };
|
||||
async saveWriteLease(edit: { optimistic?: boolean; maxWaitMinutes?: number }): Promise<WriteLeaseSettings> {
|
||||
this.lease = { ...this.lease, ...edit };
|
||||
return { ...this.lease };
|
||||
}
|
||||
|
||||
@@ -242,7 +280,7 @@ export class MockBoundary extends MockShell {
|
||||
dispatch: edit.dispatch ?? this.peerSettings.dispatch,
|
||||
max_dispatch_depth: edit.max_dispatch_depth ?? this.peerSettings.max_dispatch_depth,
|
||||
max_dispatched_sessions: edit.max_dispatched_sessions ?? this.peerSettings.max_dispatched_sessions,
|
||||
release_projects: keep ? this.peerSettings.release_projects.filter((p) => keep.includes(p.path)) : this.peerSettings.release_projects,
|
||||
release_projects: keep ? keep.map((path) => this.peerSettings.release_projects.find((p) => p.path === path) ?? { path, name: path.split(/[\\/]/).filter(Boolean).at(-1) ?? path }) : this.peerSettings.release_projects,
|
||||
pending: true,
|
||||
};
|
||||
return this.peersSettings();
|
||||
@@ -308,6 +346,15 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.changedFiles };
|
||||
}
|
||||
|
||||
async feedbackLevelNotices(): Promise<FeedbackLevelNotices> {
|
||||
return { ...levelNotices };
|
||||
}
|
||||
|
||||
async saveFeedbackLevelNotices(enabled: boolean): Promise<FeedbackLevelNotices> {
|
||||
levelNotices = { ...levelNotices, enabled };
|
||||
return { ...levelNotices };
|
||||
}
|
||||
|
||||
private foldWork: FoldWorkSettings = { fold: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async foldWorkSettings(): Promise<FoldWorkSettings> {
|
||||
@@ -412,17 +459,7 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.watch };
|
||||
}
|
||||
|
||||
// The fixture carries the broken file, because a banner nobody can reach is
|
||||
// a banner nobody designs. Repairing it here clears it, so the whole
|
||||
// interaction is reachable without a Go build.
|
||||
private broken: ConfigProblem | null = {
|
||||
path: "C:\\Users\\you\\AppData\\Roaming\\reasonix\\config.toml",
|
||||
line: 263,
|
||||
key: "plugins.command",
|
||||
excerpt: 'command = "C:\\Scripts\\mcp.exe"',
|
||||
repair: 'command = "C:\\\\Scripts\\\\mcp.exe"',
|
||||
recovered: "last-known-good",
|
||||
};
|
||||
private broken: ConfigProblem | null = null;
|
||||
|
||||
async configProblem(): Promise<ConfigProblem | null> {
|
||||
return this.broken && { ...this.broken };
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MockHub } from "./mock_hub";
|
||||
import { builtinCompositions } from "./composition";
|
||||
import { MockCompositions } from "./mock_composition";
|
||||
|
||||
describe("remote composition ownership", () => {
|
||||
it("uses each remote host's own catalog and selected composition", async () => {
|
||||
const hub = new MockHub();
|
||||
const local = await hub.open({root:"/same"});
|
||||
const remote = await hub.openRemote({host:"gpu-box",workspace:"/same",agentComposition:"builtin:minimal"});
|
||||
const another = await hub.openRemote({host:"builder",workspace:"/same"});
|
||||
const custom = {...builtinCompositions()[0],id:"user:only-remote",name:"Remote only"};
|
||||
await hub.portFor(remote).saveAgentPreset(custom,"");
|
||||
expect(remote.agentComposition?.id).toBe("builtin:minimal");
|
||||
expect((await hub.portFor(local).agentPresets()).presets.some(v=>v.definition.id===custom.id)).toBe(false);
|
||||
expect((await hub.portFor(another).agentPresets()).presets.some(v=>v.definition.id===custom.id)).toBe(false);
|
||||
const sibling = await hub.openRemote({host:"gpu-box",workspace:"/same",agentComposition:custom.id});
|
||||
expect(sibling.agentComposition?.id).toBe(custom.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project composition authority", () => {
|
||||
it.each([
|
||||
["mcp", {args:["other-package"]}],
|
||||
["mcp", {url:"https://other.invalid/mcp"}],
|
||||
["mcp", {env:{PATH:"${OTHER_PATH}"}}],
|
||||
["mcp", {headers:{Authorization:"${OTHER_TOKEN}"}}],
|
||||
["extensions", {args:["other-script"]}],
|
||||
["extensions", {env:{NODE_OPTIONS:"${OTHER_OPTIONS}"}}],
|
||||
] as const)("rejects project %s launch overrides on save and import", async (kind, parameters) => {
|
||||
const presets = new MockCompositions();
|
||||
const definition = {...builtinCompositions()[0],id:"project:launch",[kind]:{mode:"select",members:[{id:"authorized",parameters}]}};
|
||||
await expect(presets.saveAgentPreset(definition,"")).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}});
|
||||
await expect(presets.importAgentPreset(definition)).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}});
|
||||
await expect(presets.saveAgentPreset({...definition,id:"user:launch"},"")).resolves.toMatchObject({definition:{id:"user:launch"}});
|
||||
});
|
||||
|
||||
it("allows project timeout adjustments", async () => {
|
||||
const presets = new MockCompositions();
|
||||
const definition = {...builtinCompositions()[0],id:"project:timeouts",mcp:{mode:"select" as const,members:[{id:"authorized",parameters:{callTimeoutSeconds:5,startupTimeoutSeconds:10}}]}};
|
||||
await expect(presets.saveAgentPreset(definition,"")).resolves.toMatchObject({definition});
|
||||
await expect(presets.importAgentPreset(definition)).resolves.toMatchObject({definition});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import revisions from "../../../../internal/contract/agentcomposition/builtin-revisions.json";
|
||||
import { builtinCompositions } from "./composition";
|
||||
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
|
||||
|
||||
type Library = { revision:number; user:AgentPresetVersion[]; projects:Map<string,AgentPresetVersion[]>; personalDefault:string; workspaceDefaults:Map<string,string> };
|
||||
|
||||
export class MockCompositions {
|
||||
private current: CompositionSummary | null = null;
|
||||
private started = false;
|
||||
constructor(private readonly library:Library = {revision:0,user:[],projects:new Map(),personalDefault:"builtin:general",workspaceDefaults:new Map()},private readonly workspace = "") {
|
||||
}
|
||||
get compositionStarted() { return this.started; }
|
||||
fork(workspace:string) { return new MockCompositions(this.library,workspace); }
|
||||
private get catalog():AgentPresetCatalog {
|
||||
return {presets:[...builtinCompositions().map((definition)=>({definition,revision:revisions[definition.id as keyof typeof revisions]})),...this.library.user,...(this.library.projects.get(this.workspace) ?? [])],default:this.library.workspaceDefaults.get(this.workspace) ?? this.library.personalDefault,diagnostics:[]};
|
||||
}
|
||||
private summary(v:AgentPresetVersion):CompositionSummary { return {id:v.definition.id,name:v.definition.name,revision:v.revision,digest:`mock-${v.revision}`,started:false}; }
|
||||
private fail(status:number,code:string):never { throw new HttpError(status,"",{code:`agent_composition.${code}`}); }
|
||||
private validateAuthority(definition:AgentComposition) {
|
||||
if (!definition.id.startsWith("project:")) return;
|
||||
for (const kind of ["mcp","extensions"] as const) for (const member of definition[kind]?.members ?? []) {
|
||||
if (Object.entries(member.parameters ?? {}).some(([key,value])=>["args","url","env","headers"].includes(key.toLowerCase()) && value!=null)) this.fail(400,"invalid");
|
||||
}
|
||||
}
|
||||
async agentPresets() { return structuredClone(this.catalog); }
|
||||
async saveAgentPreset(definition: AgentComposition, expectedRevision: string): Promise<AgentPresetVersion> {
|
||||
if (definition.id.startsWith("builtin:")) this.fail(403,"read_only");
|
||||
const prior=this.catalog.presets.find((v)=>v.definition.id===definition.id);
|
||||
if ((prior?.revision ?? "")!==expectedRevision) this.fail(409,"conflict");
|
||||
if (!/^(user|project):[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(definition.id) || !definition.name.trim()) this.fail(400,"invalid");
|
||||
this.validateAuthority(definition);
|
||||
const version={definition:structuredClone(definition),revision:`mock-${++this.library.revision}`};
|
||||
const items=definition.id.startsWith("project:") ? this.library.projects.get(this.workspace) ?? [] : this.library.user;
|
||||
const next=[...items.filter((v)=>v.definition.id!==definition.id),version];
|
||||
if (definition.id.startsWith("project:")) this.library.projects.set(this.workspace,next); else this.library.user=next;
|
||||
return structuredClone(version);
|
||||
}
|
||||
async deleteAgentPreset(id:string,revision:string) {
|
||||
if (id.startsWith("builtin:")) this.fail(403,"read_only");
|
||||
const old=this.catalog.presets.find((v)=>v.definition.id===id);
|
||||
if (!old || old.revision!==revision) this.fail(409,"conflict");
|
||||
this.library.user=this.library.user.filter((v)=>v.definition.id!==id);
|
||||
this.library.projects.set(this.workspace,(this.library.projects.get(this.workspace) ?? []).filter((v)=>v.definition.id!==id));
|
||||
}
|
||||
async setDefaultAgentPreset(id:string,workspace:boolean) {
|
||||
if (!this.catalog.presets.some((v)=>v.definition.id===id)) this.fail(404,"not_found");
|
||||
if (!workspace && id.startsWith("project:")) this.fail(400,"invalid");
|
||||
if (workspace) this.library.workspaceDefaults.set(this.workspace,id); else this.library.personalDefault=id;
|
||||
}
|
||||
async exportAgentPreset(id:string) {
|
||||
const v=this.catalog.presets.find((v)=>v.definition.id===id);
|
||||
if (!v) this.fail(404,"not_found");
|
||||
return structuredClone(v.definition);
|
||||
}
|
||||
async importAgentPreset(content:AgentComposition) {
|
||||
if (content.schemaVersion!==1 || !content.name || !content.persona) this.fail(400,"invalid");
|
||||
this.validateAuthority(content);
|
||||
return {definition:structuredClone(content),diagnostics:[]};
|
||||
}
|
||||
async agentComposition() { return structuredClone(this.current); }
|
||||
async selectAgentComposition(id:string) {
|
||||
const v=this.catalog.presets.find((v)=>v.definition.id===id);
|
||||
if (!v) this.fail(404,"not_found");
|
||||
if (this.current?.id===id && this.current.revision===v.revision || !this.current && id==="builtin:general") return structuredClone(this.current);
|
||||
if (this.started) this.fail(409,"locked");
|
||||
this.current=id==="builtin:general" ? null : this.summary(v);
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
markCompositionStarted() { this.started=true; if(this.current) this.current.started=true; }
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockCommit } from "./mock_commit";
|
||||
import { FEEDBACK_CODE, canWithdraw, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
import { FEEDBACK_CODE, canWithdraw, type FeedbackBoard, type FeedbackBoardEntry, type FeedbackBoardMe, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
|
||||
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
|
||||
|
||||
const ENV = {
|
||||
version: "v2.24.0", commit: "7ee4bbb", surface: "studio", os: "windows", osVersion: "10.0.19045",
|
||||
arch: "amd64", locale: "en-US", channel: "stable", providerKind: "deepseek",
|
||||
memory: [{ process: "kernel" as const, workingSetKB: 196608, privateKB: 150000 }],
|
||||
};
|
||||
|
||||
const DAY = 86_400_000;
|
||||
@@ -57,6 +58,7 @@ function fault(key = FAULT): string {
|
||||
// the same reason: "l0".."l6", "legacy", "lapsed", "revoked", or "none" for a
|
||||
// service that states no profile.
|
||||
const STANDING = "rx-mock-feedback-standing";
|
||||
const PROMOTION = "rx-mock-feedback-promotion";
|
||||
|
||||
const THRESHOLDS = [0, 1, 3, 6, 12, 24, 48];
|
||||
const LEVEL_LIMITS = [[3, 10, 3], [5, 15, 4], [6, 20, 5], [8, 25, 6], [10, 30, 8], [12, 40, 10], [12, 60, 10]] as const;
|
||||
@@ -129,7 +131,19 @@ export class MockFeedback extends MockCommit {
|
||||
return { ...i, needsInput: i.statusUnavailable ? false : i.needsInput, unreadReplies };
|
||||
});
|
||||
const unread = items.filter((i) => i.unreadReplies > 0 || i.needsInput).length;
|
||||
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)) };
|
||||
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)), promotion: this.promotion() };
|
||||
}
|
||||
|
||||
// A tab's own sessionStorage ("2:3") stages a level-up for a dev page, until it is acknowledged.
|
||||
private promotion(): { from: number; to: number } | null {
|
||||
const [from, to] = fault(PROMOTION).split(":").map(Number);
|
||||
return Number.isInteger(from) && Number.isInteger(to) && to > from && !this.acked.has(to) ? { from, to } : null;
|
||||
}
|
||||
|
||||
private acked = new Set<number>();
|
||||
|
||||
async ackFeedbackPromotion(level: number): Promise<void> {
|
||||
this.acked.add(level);
|
||||
}
|
||||
|
||||
announced = 0;
|
||||
@@ -181,6 +195,55 @@ export class MockFeedback extends MockCommit {
|
||||
this.links = this.links.filter((l) => !l.current);
|
||||
}
|
||||
|
||||
// The demo board: a handful of people, credits spread over three releases.
|
||||
private board: FeedbackBoardEntry[] = [
|
||||
{ displayName: "Sample Alpha", credits: 7, rank: null, releases: [{ version: "v2.45.0", count: 3 }, { version: "v2.44.0", count: 2 }, { version: "v2.43.0", count: 2 }] },
|
||||
{ displayName: "示例用户", credits: 5, rank: null, releases: [{ version: "v2.45.0", count: 2 }, { version: "v2.44.0", count: 3 }] },
|
||||
{ displayName: "sample_beta", credits: 3, rank: null, releases: [{ version: "v2.45.0", count: 2 }, { version: "v2.43.0", count: 1 }] },
|
||||
{ displayName: "Sample-Gamma", credits: 2, rank: null, releases: [{ version: "v2.44.0", count: 1 }, { version: "v2.43.0", count: 1 }] },
|
||||
{ displayName: "Sample Delta", credits: 1, rank: null, releases: [{ version: "v2.45.0", count: 1 }] },
|
||||
];
|
||||
private chosen: string | null = null;
|
||||
|
||||
private boardMe(): FeedbackBoardMe {
|
||||
return { listed: this.chosen !== null, displayName: this.chosen, delisted: false };
|
||||
}
|
||||
|
||||
async feedbackBoard(release?: string): Promise<FeedbackBoard> {
|
||||
if (!release) {
|
||||
const all = [...this.board].sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
return { available: true, view: "all", release: null, entries: all };
|
||||
}
|
||||
const rows = this.board
|
||||
.map((e) => ({ ...e, releases: e.releases.filter((r) => r.version === release) }))
|
||||
.filter((e) => e.releases.length > 0)
|
||||
.map((e) => ({ ...e, credits: e.releases.reduce((n, r) => n + r.count, 0) }))
|
||||
.sort((a, b) => b.credits - a.credits || a.displayName.localeCompare(b.displayName));
|
||||
let rank = 0;
|
||||
const entries = rows.map((e, i) => {
|
||||
if (i === 0 || e.credits !== rows[i - 1].credits) rank = i + 1;
|
||||
return { ...e, rank };
|
||||
});
|
||||
return { available: true, view: "release", release, entries };
|
||||
}
|
||||
|
||||
async feedbackBoardMe(): Promise<FeedbackBoardMe> {
|
||||
this.requireAccount();
|
||||
return this.boardMe();
|
||||
}
|
||||
|
||||
async joinFeedbackBoard(displayName: string): Promise<FeedbackBoardMe> {
|
||||
this.requireAccount();
|
||||
this.chosen = displayName.trim();
|
||||
return this.boardMe();
|
||||
}
|
||||
|
||||
async leaveFeedbackBoard(): Promise<FeedbackBoardMe> {
|
||||
this.requireAccount();
|
||||
this.chosen = null;
|
||||
return this.boardMe();
|
||||
}
|
||||
|
||||
async unlinkFeedbackInstall(id: string): Promise<void> {
|
||||
this.requireAccount();
|
||||
this.links = this.links.filter((l) => l.id !== id);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import type { ChangeDiff, DiscardRequest, GitCommitFiles, GitRepoRow, GitRepos, PushPlan, PushRequest, PushResult, WorkspaceChange } from "./workspace";
|
||||
import { MockFeedback } from "./mock_feedback";
|
||||
|
||||
const refuse = (status: number, code: string, error: string) => new HttpError(status, error, { code, error });
|
||||
|
||||
// The git panel's reads and writes beyond the workspace's own changes: which
|
||||
// repositories there are, what a commit holds, a discard and a push. Scripted,
|
||||
// because there is no repository behind the fixture, but held in fields so a
|
||||
// write is seen by the next read the way the kernel's would be.
|
||||
export class MockGit extends MockFeedback {
|
||||
// One entry is the workspace being a repository; several are a folder of projects.
|
||||
repoRows: GitRepoRow[] = [{ dir: "", name: "reasonix", branch: "main", detached: false }];
|
||||
|
||||
// The changes of each repository below the workspace; the workspace's own are MockWorkspace.changeList.
|
||||
repoChanges: Record<string, WorkspaceChange[]> = {};
|
||||
|
||||
async gitRepos(): Promise<GitRepos> {
|
||||
return { repos: this.repoRows.map((r) => ({ ...r })), truncated: false };
|
||||
}
|
||||
|
||||
async discardChange(req: DiscardRequest): Promise<void> {
|
||||
const list = req.repo ? this.repoChanges[req.repo] : (this as unknown as { changeList: WorkspaceChange[] }).changeList;
|
||||
const found = list?.find((c) => c.path === req.path);
|
||||
if (!found) throw refuse(400, "discard.unknown_path", "not a changed file in the workspace");
|
||||
if (!["M", "D", "MM"].includes(found.status)) throw refuse(409, "discard.not_discardable", "only a change to a file the last commit holds can be discarded");
|
||||
const rest = list.filter((c) => c !== found);
|
||||
if (req.repo) this.repoChanges[req.repo] = rest;
|
||||
else (this as unknown as { changeList: WorkspaceChange[] }).changeList = rest;
|
||||
}
|
||||
|
||||
async commitFiles(id: string): Promise<GitCommitFiles> {
|
||||
if (!/^[0-9a-f]{1,64}$/i.test(id)) throw refuse(400, "git.commit_bad_id", "that is not a commit id");
|
||||
return {
|
||||
repo: true,
|
||||
truncated: false,
|
||||
files: [
|
||||
{ path: "internal/provider/retry.go", status: "M", insertions: 14, deletions: 3 },
|
||||
{ path: "internal/provider/retry_test.go", status: "A", insertions: 42 },
|
||||
{ path: "docs/old-notes.md", oldPath: "docs/notes.md", status: "R", insertions: 1, deletions: 1 },
|
||||
{ path: "assets/logo.png", status: "M" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async commitDiff(_id: string, path: string): Promise<ChangeDiff> {
|
||||
return {
|
||||
path,
|
||||
truncated: false,
|
||||
diff: [`--- a/${path}`, `+++ b/${path}`, "@@ -1,3 +1,4 @@", " package provider", "-func retry() {}", "+func retry(after time.Duration) {", "+ time.Sleep(after)", " }"].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
// Commits the branch has that the upstream does not. Pushing sets it to zero.
|
||||
pushAhead = 2;
|
||||
pushScript: "ok" | "rejected" | "auth" | "untrusted" | "protocol" = "ok";
|
||||
|
||||
private plan(repo?: string): PushPlan {
|
||||
return {
|
||||
remote: "origin", branch: "main", upstreamBranch: "main", scheme: "https", host: "github.com",
|
||||
url: "https://github.com/esengine/DeepSeek-Reasonix.git", head: repo ? "9f2c41b07ad3" : "0123456789ab", ahead: this.pushAhead, behind: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async pushPreview(repo?: string): Promise<PushPlan> {
|
||||
if (this.pushScript === "untrusted") throw refuse(403, "push.workspace_untrusted", "this workspace is not trusted");
|
||||
if (this.pushScript === "protocol") throw refuse(403, "push.protocol_not_allowed", "the remote's protocol is not allowed");
|
||||
return this.plan(repo);
|
||||
}
|
||||
|
||||
async push(req: PushRequest): Promise<PushResult> {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
if (this.pushAhead === 0) throw refuse(409, "push.nothing_to_push", "nothing to push");
|
||||
if (this.pushScript === "rejected") throw refuse(409, "push.rejected", "the remote has commits this branch does not");
|
||||
if (this.pushScript === "auth") throw refuse(409, "push.auth_required", "the remote needs authentication");
|
||||
this.pushAhead = 0;
|
||||
return { plan: req.plan, new: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
import { expect, it } from "vitest";
|
||||
import { MockHub } from "./mock_hub";
|
||||
|
||||
it("keeps session placement and pin/archive flags in the same mock tree", async () => {
|
||||
const hub = new MockHub();
|
||||
const original = (await hub.tree())[0].sessions;
|
||||
const [first, second] = original;
|
||||
await hub.pinSession(second.path, true);
|
||||
await hub.moveSession(second.path, -1);
|
||||
const moved = (await hub.tree())[0].sessions;
|
||||
expect(moved.map((s) => s.path)).toEqual([second.path, first.path]);
|
||||
expect(moved[0].pinned).toBe(true);
|
||||
await hub.archiveSession(second.path, true);
|
||||
const archived = (await hub.tree())[0].sessions;
|
||||
expect(archived.map((s) => s.path)).toEqual([second.path, first.path]);
|
||||
expect(archived[0]).toMatchObject({ archived: true, pinned: false });
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentPort } from "./port";
|
||||
import type { HistoryHit, HubPort, RuntimeView, TreeSession, TreeWindow, TreeWorkspace } from "./hub";
|
||||
import type { RemoteHost, RemoteListing, RemoteProbe } from "./remote";
|
||||
import type { ShareStatus } from "./share";
|
||||
import type { PhoneAction, PhoneList, PhoneView, ShareStatus } from "./share";
|
||||
import { MockPort } from "./mock";
|
||||
|
||||
// MockHub is the fixture's answer to a window that drives several panes. Each
|
||||
@@ -11,17 +11,24 @@ export class MockHub implements HubPort {
|
||||
private readonly ports = new Map<string, AgentPort>();
|
||||
private readonly machine = { configured: false };
|
||||
private readonly settings = new MockPort();
|
||||
private readonly remoteSettings = new Map<string, MockPort>();
|
||||
private readonly views: RuntimeView[] = [
|
||||
{ id: "r1", base: "", root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", sessionPath: "/sessions/mock.jsonl" },
|
||||
];
|
||||
private readonly roots = ["~/projects/DeepSeek-Reasonix", "~/projects/my-website"];
|
||||
private seq = 1;
|
||||
private readonly flags = new Map<string, Pick<TreeSession, "pinned" | "archived">>();
|
||||
|
||||
private decorate(tree: TreeWorkspace[], host = ""): TreeWorkspace[] {
|
||||
return tree.map((ws) => ({ ...ws, sessions: ws.sessions.map((session) => ({ ...session,
|
||||
...this.flags.get(JSON.stringify([host, session.path])) })) }));
|
||||
}
|
||||
|
||||
runtimes() {
|
||||
return Promise.resolve([...this.views]);
|
||||
}
|
||||
|
||||
open(req: { root?: string; sessionPath?: string }) {
|
||||
async open(req: { root?: string; sessionPath?: string; agentComposition?:string }) {
|
||||
const held = req.sessionPath ? this.views.find((v) => v.sessionPath === req.sessionPath) : undefined;
|
||||
if (held) return Promise.resolve(held);
|
||||
this.seq++;
|
||||
@@ -33,6 +40,9 @@ export class MockHub implements HubPort {
|
||||
name: root.split("/").pop() ?? root,
|
||||
sessionPath: req.sessionPath,
|
||||
};
|
||||
const port=this.portFor(view);
|
||||
if (req.agentComposition) await port.selectAgentComposition(req.agentComposition);
|
||||
view.agentComposition=await port.agentComposition() ?? undefined;
|
||||
this.views.push(view);
|
||||
return Promise.resolve(view);
|
||||
}
|
||||
@@ -60,7 +70,7 @@ export class MockHub implements HubPort {
|
||||
// the tree under a connected host can be designed without one.
|
||||
remoteTree(host: string) {
|
||||
if (host !== "gpu-box") return Promise.resolve(null);
|
||||
return Promise.resolve<TreeWorkspace[]>([
|
||||
return Promise.resolve<TreeWorkspace[]>(this.decorate([
|
||||
{
|
||||
root: "/srv/training",
|
||||
name: "training",
|
||||
@@ -70,10 +80,10 @@ export class MockHub implements HubPort {
|
||||
],
|
||||
},
|
||||
{ root: "/srv/scratch", name: "scratch", sessions: [] },
|
||||
]);
|
||||
], host));
|
||||
}
|
||||
|
||||
openRemote(req: { host: string; workspace?: string; sessionPath?: string }) {
|
||||
async openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?: string }) {
|
||||
this.seq++;
|
||||
const workspace = req.workspace || "/srv/training";
|
||||
const view: RuntimeView = {
|
||||
@@ -84,6 +94,9 @@ export class MockHub implements HubPort {
|
||||
host: req.host,
|
||||
sessionPath: req.sessionPath,
|
||||
};
|
||||
const port = this.portFor(view);
|
||||
if (req.agentComposition) await port.selectAgentComposition(req.agentComposition);
|
||||
view.agentComposition = await port.agentComposition() ?? undefined;
|
||||
this.views.push(view);
|
||||
return Promise.resolve(view);
|
||||
}
|
||||
@@ -168,7 +181,7 @@ export class MockHub implements HubPort {
|
||||
tree(_window?: TreeWindow) {
|
||||
const open = new Map(this.views.filter((v) => v.sessionPath).map((v) => [v.sessionPath!, v.id]));
|
||||
return Promise.resolve(
|
||||
this.roots.map<TreeWorkspace>((root, i) => ({
|
||||
this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
|
||||
root,
|
||||
name: root.split("/").pop() ?? root,
|
||||
open: this.views.some((v) => v.root === root),
|
||||
@@ -180,7 +193,7 @@ export class MockHub implements HubPort {
|
||||
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12, runtimeId: open.get("/sessions/older.jsonl"), legacy: true },
|
||||
]
|
||||
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }]),
|
||||
})),
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,11 +272,15 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
archiveSession(_path: string, _archived: boolean) {
|
||||
archiveSession(path: string, archived: boolean, host = "") {
|
||||
const key = JSON.stringify([host, path]);
|
||||
this.flags.set(key, { ...this.flags.get(key), archived, ...(archived ? { pinned: false } : {}) });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
pinSession(_path: string, _pinned: boolean) {
|
||||
pinSession(path: string, pinned: boolean, host = "") {
|
||||
const key = JSON.stringify([host, path]);
|
||||
this.flags.set(key, { ...this.flags.get(key), pinned });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -288,6 +305,10 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0, recognised: true, skipped: [] });
|
||||
}
|
||||
|
||||
importNativeSession(_file: Blob, _workspace: string) {
|
||||
return Promise.resolve({ path: `/sessions/imported-${++this.seq}.jsonl` });
|
||||
}
|
||||
|
||||
hostCapabilities() {
|
||||
return Promise.resolve({ pickFolder: true, addWorkspace: true });
|
||||
}
|
||||
@@ -308,6 +329,25 @@ export class MockHub implements HubPort {
|
||||
cloudRemote: { online: false, reason: "signed_out" },
|
||||
};
|
||||
|
||||
private phones: PhoneView[] = [
|
||||
{ id: "rc_aaa", ordinal: 1, name: "iPhone Safari", state: "active", uaClass: "ios-safari", createdAt: "2026-10-12T08:00:00Z", lastSeenAt: "2026-10-18T09:30:00Z", keyThumbprint: "kP3x…f9a1" },
|
||||
{ id: "rc_bbb", name: "Android Chrome", state: "pending", uaClass: "android-chrome", createdAt: "2026-10-18T09:40:00Z", lastSeenAt: "2026-10-18T09:40:00Z", expiresAt: "2026-10-18T09:55:00Z", keyThumbprint: "Zq8m…02c7" },
|
||||
];
|
||||
|
||||
sharePhones() {
|
||||
return Promise.resolve<PhoneList | null>({ phones: this.phones.map((p) => ({ ...p })) });
|
||||
}
|
||||
|
||||
resolvePhone(id: string, action: PhoneAction) {
|
||||
this.phones = this.phones.flatMap((p) => (p.id !== id ? [p] : action === "reject" ? [] : [{ ...p, state: "active" as const, expiresAt: undefined, ordinal: 2 }]));
|
||||
return Promise.resolve<PhoneList>({ phones: this.phones.map((p) => ({ ...p })) });
|
||||
}
|
||||
|
||||
revokePhone(id: string) {
|
||||
this.phones = this.phones.filter((p) => p.id !== id);
|
||||
return Promise.resolve<PhoneList>({ phones: this.phones.map((p) => ({ ...p })) });
|
||||
}
|
||||
|
||||
shareStatus() {
|
||||
return Promise.resolve<ShareStatus | null>({ ...this.share });
|
||||
}
|
||||
@@ -378,6 +418,12 @@ export class MockHub implements HubPort {
|
||||
const held = this.ports.get(rt.id);
|
||||
if (held) return held;
|
||||
const port = new MockPort();
|
||||
let settings = this.settings;
|
||||
if (rt.host) {
|
||||
settings = this.remoteSettings.get(rt.host) ?? new MockPort();
|
||||
this.remoteSettings.set(rt.host, settings);
|
||||
}
|
||||
port.shareAgentPresetsFrom(settings,rt.root);
|
||||
port.machine = this.machine;
|
||||
this.ports.set(rt.id, port);
|
||||
return port;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { MemoryEntry } from "./port";
|
||||
import type { MemoryCatalog, MemoryEntry } from "./port";
|
||||
|
||||
export function mockMemoryCatalog(memories: MemoryEntry[]): MemoryCatalog {
|
||||
return {
|
||||
memories: memories.map((m) => ({ ...m })),
|
||||
recallQuery: "缓存命中率为什么会掉",
|
||||
indexPath: "~/.reasonix/projects/reasonix/memory/MEMORY.md",
|
||||
};
|
||||
}
|
||||
|
||||
// The memory shelf the window opens onto with no kernel behind it. Data only:
|
||||
// how a save or a restore moves it is the port's, and lives beside the calls
|
||||
|
||||
Loaded 100 of 1110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user