diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..4cd6bdd3 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "oneagent-frontend", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "frontend", "run", "dev"], + "port": 5173 + } + ] +} diff --git a/.dockerignore b/.dockerignore index 0297ee6c..594ea4b1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,20 +1,16 @@ .git .gitignore .env* -.venv -venv -__pycache__ -*.py[cod] *.key *.pem *.p12 auth.json .npmrc -.pypirc .netrc .docker .aws .qoder +bin .codex .claude .oneagent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ffb4f49f..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,210 +0,0 @@ -name: CI - -on: - # Automatic runs are off at the repository owner's request. The jobs below are - # unchanged and still run on demand from the Actions tab, so restoring - # continuous checks means putting the pull_request and push triggers back -- - # nothing else here has to change. - workflow_dispatch: - -permissions: - contents: read - -jobs: - container-cleanroom: - name: Docker Linux cleanroom - runs-on: ubuntu-22.04 - - steps: - - uses: actions/checkout@v4 - - - name: Build and run isolated Linux cleanroom - run: bash scripts/test_docker_cleanroom.sh - - - name: Upload cleanroom evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: docker-linux-cleanroom - path: build/docker-cleanroom - if-no-files-found: ignore - - contract-and-browser: - name: ${{ matrix.label }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-22.04 - label: Linux x64 - - os: windows-2022 - label: Windows x64 - - os: macos-15 - label: macOS arm64 - - os: macos-15-intel - label: macOS x64 - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Install Python test tools - run: python -m pip install coverage==7.15.2 - - - name: Install macOS packaging tool - if: runner.os == 'macOS' - run: python -m pip install pyinstaller==6.21.0 - - - name: Install frontend dependencies - working-directory: frontend - run: npm ci - - - name: Build frontend - working-directory: frontend - run: npm run build - - - name: Python contracts and coverage - shell: bash - run: | - mkdir -p build/coverage - coverage erase - coverage run --branch -m unittest tests.test_core tests.test_cli tests.test_server tests.test_release_policy tests.test_distribution_data tests.test_edge_cases tests.test_rc_scripts tests.test_install_contract tests.test_config_discovery - coverage report --fail-under=85 - coverage json - python -c "import json, os; files = json.load(open('build/coverage/coverage.json', encoding='utf-8'))['files']; normalized = {key.replace(os.sep, '/'): value for key, value in files.items()}; summary = normalized['oneagent/installer.py']['summary']; assert summary['percent_branches_covered'] == 100 and summary['num_partial_branches'] == 0, summary" - - - name: Bash compatibility contracts - if: runner.os != 'Windows' - run: bash tests/install_test.sh - - - name: GUI compatibility smoke - if: runner.os != 'Windows' - run: python tests/gui_smoke_test.py - - - name: PowerShell wrapper contract - if: runner.os == 'Windows' - shell: pwsh - run: | - $homeDir = Join-Path $env:RUNNER_TEMP "oneagent-contract-home" - ./scripts/install.ps1 --agent openclaw --check-agent-only --json --home $homeDir - - - name: Wheel builds and runs from a clean install - run: python scripts/verify_wheel.py - - - name: React unit coverage - working-directory: frontend - run: npm run test:coverage - - - name: Install Chromium - working-directory: frontend - run: npx playwright install chromium - - - name: Browser E2E - working-directory: frontend - run: npm run e2e - - - name: Build native macOS onedir - if: runner.os == 'macOS' - run: python scripts/build_release.py --channel technical-preview-unsigned --skip-frontend - - - name: Real macOS cleanroom - if: runner.os == 'macOS' - env: - ONEAGENT_PACKAGED_BINARY: ${{ github.workspace }}/build/pyinstaller-dist/OneAgent/OneAgent - run: bash tests/macos_cleanroom_test.sh - - - name: Validate macOS arm64 release for the public site - if: matrix.os == 'macos-15' - run: python scripts/check_release.py release - - - name: Upload verified macOS arm64 release for the public site - if: matrix.os == 'macos-15' - uses: actions/upload-artifact@v4 - with: - name: public-site-release-macos-arm64 - path: release/* - if-no-files-found: error - - - name: Upload browser failures - if: failure() - uses: actions/upload-artifact@v4 - with: - name: browser-failures-${{ matrix.os }} - path: frontend/test-results - if-no-files-found: ignore - - - name: Upload macOS cleanroom failures - if: failure() && runner.os == 'macOS' - uses: actions/upload-artifact@v4 - with: - name: macos-cleanroom-failures-${{ matrix.os }} - path: build/macos-cleanroom - if-no-files-found: ignore - - - public-site: - name: Public distribution site - needs: contract-and-browser - runs-on: ubuntu-22.04 - - steps: - - uses: actions/checkout@v4 - - - name: Download verified macOS arm64 release - uses: actions/download-artifact@v4 - with: - name: public-site-release-macos-arm64 - path: release - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: site/package-lock.json - - - name: Install site dependencies - working-directory: site - run: npm ci - - - name: Unit, type and release-data checks - working-directory: site - run: | - npm test - npm run build - - - name: Install Chromium - working-directory: site - run: npx playwright install --with-deps chromium - - - name: Browser and accessibility checks - working-directory: site - run: npm run test:e2e - - - name: Verify GitHub Pages base path build - working-directory: site - env: - SITE_URL: https://example.com - BASE_PATH: /OneAgent - run: npm run build - - - name: Upload site browser failures - if: failure() - uses: actions/upload-artifact@v4 - with: - name: public-site-browser-failures - path: site/test-results - if-no-files-found: ignore diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml deleted file mode 100644 index 427a333c..00000000 --- a/.github/workflows/release-candidate.yml +++ /dev/null @@ -1,153 +0,0 @@ -name: Release Candidate Verification - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - native-build-and-agent-install: - name: ${{ matrix.label }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-22.04 - label: Linux x64 - source: "--source" - - os: windows-2022 - label: Windows x64 - source: "" - - os: macos-15 - label: macOS arm64 - source: "" - - os: macos-15-intel - label: macOS x64 - source: "" - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: astral-sh/setup-uv@v7 - with: - version: "0.11.20" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Install build dependencies - run: python -m pip install pyinstaller==6.21.0 - - - name: Build frontend - working-directory: frontend - run: | - npm ci - npm run test:coverage - npm run build - - - name: Run Python contracts - run: python -m unittest tests.test_core tests.test_cli tests.test_server tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts tests.test_install_contract tests.test_config_discovery - - - name: Build unsigned native package - shell: bash - run: python scripts/build_release.py --channel technical-preview-unsigned --skip-frontend ${{ matrix.source }} - - - name: Smoke packaged executable on Unix - if: runner.os != 'Windows' - shell: bash - run: | - home_dir="$(mktemp -d)" - build/pyinstaller-dist/OneAgent/OneAgent --agent openclaw --check-agent-only --json --home "$home_dir" - - - name: Real macOS cleanroom - if: runner.os == 'macOS' - env: - ONEAGENT_PACKAGED_BINARY: ${{ github.workspace }}/build/pyinstaller-dist/OneAgent/OneAgent - run: bash tests/macos_cleanroom_test.sh - - - name: Smoke packaged executable on Windows - if: runner.os == 'Windows' - shell: pwsh - run: | - $homeDir = Join-Path $env:RUNNER_TEMP "oneagent-package-home" - ./build/pyinstaller-dist/OneAgent/OneAgent.exe --agent openclaw --check-agent-only --json --home $homeDir - - - name: Install and verify all locked Agents - run: python scripts/verify_locked_agents.py - - # verify_locked_agents resolves commands through an injected which() over a - # PATH it built itself, so it proves OneAgent believes the install worked. - # This proves the shell agrees: npm's global prefix differs per machine and - # a binary that never lands on PATH is the common real-world failure. - - name: Verify a blank machine can run Codex and Claude Code - if: runner.os != 'Windows' - shell: bash - run: bash tests/real_install_test.sh - - # A configured Agent can still fail to adopt what we wrote -- the Claude - # Code "Not logged in" defect, found outside the real-key threshold. This - # points the config at the discard port with no key and asserts the failure - # is at the network layer (config adopted), not the auth layer (ignored). - # It installs the Agents itself, so it needs no secret and no prior step's - # prefix. - - name: Verify configured Agents adopt their config without a key - if: runner.os != 'Windows' - run: python scripts/agent_config_adopted_check.py - - - name: Upload blank-machine verification failures - if: failure() && runner.os != 'Windows' - uses: actions/upload-artifact@v4 - with: - name: real-install-${{ matrix.os }} - path: build/real-install - if-no-files-found: ignore - - - name: Validate release contents - run: python scripts/check_release.py release - - - name: Upload macOS cleanroom failures - if: failure() && runner.os == 'macOS' - uses: actions/upload-artifact@v4 - with: - name: macos-cleanroom-failures-${{ matrix.os }} - path: build/macos-cleanroom - if-no-files-found: ignore - - - uses: actions/upload-artifact@v4 - with: - name: OneAgent-${{ matrix.os }}-release-candidate-unsigned - path: release/* - if-no-files-found: error - - provider-protocol-smoke: - name: PPIO and Novita protocol smoke - needs: native-build-and-agent-install - runs-on: ubuntu-22.04 - env: - ONEAGENT_PPIO_API_KEY: ${{ secrets.ONEAGENT_PPIO_API_KEY }} - ONEAGENT_PPIO_OPENAI_MODEL: ${{ vars.ONEAGENT_PPIO_OPENAI_MODEL }} - ONEAGENT_PPIO_ANTHROPIC_MODEL: ${{ vars.ONEAGENT_PPIO_ANTHROPIC_MODEL }} - ONEAGENT_PPIO_RESPONSES_MODEL: ${{ vars.ONEAGENT_PPIO_RESPONSES_MODEL }} - ONEAGENT_NOVITA_API_KEY: ${{ secrets.ONEAGENT_NOVITA_API_KEY }} - ONEAGENT_NOVITA_OPENAI_MODEL: ${{ vars.ONEAGENT_NOVITA_OPENAI_MODEL }} - ONEAGENT_NOVITA_ANTHROPIC_MODEL: ${{ vars.ONEAGENT_NOVITA_ANTHROPIC_MODEL }} - ONEAGENT_NOVITA_RESPONSES_MODEL: ${{ vars.ONEAGENT_NOVITA_RESPONSES_MODEL }} - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Run low-token protocol checks - run: python scripts/provider_rc_smoke.py --provider all --timeout 30 diff --git a/.github/workflows/technical-preview.yml b/.github/workflows/technical-preview.yml deleted file mode 100644 index f4e0f9ee..00000000 --- a/.github/workflows/technical-preview.yml +++ /dev/null @@ -1,263 +0,0 @@ -name: Technical Preview Packages - -on: - workflow_dispatch: - inputs: - release_tag: - description: Draft release tag - required: true - default: v0.2.0-dev-preview.1 - type: string - create_draft_release: - description: Create an immutable draft GitHub prerelease - required: true - default: true - type: boolean - deploy_pages: - description: Deploy after the draft release has been reviewed and published - required: true - default: false - type: boolean - push: - tags: - - "v*-preview*" - -permissions: - contents: write - pages: write - id-token: write - -concurrency: - group: technical-preview-${{ github.ref }} - cancel-in-progress: false - -jobs: - build: - name: ${{ matrix.label }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-22.04 - label: Linux x64 - source: "--source" - - os: windows-2022 - label: Windows x64 - source: "" - - os: macos-15 - label: macOS arm64 - source: "" - - os: macos-15-intel - label: macOS x64 - source: "" - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Install build dependencies - run: python -m pip install pyinstaller==6.21.0 - - - name: Install and build frontend - working-directory: frontend - run: | - npm ci - npm run build - - - name: Build unsigned onedir package - shell: bash - run: python scripts/build_release.py --channel technical-preview-unsigned --skip-frontend ${{ matrix.source }} - - - name: Smoke packaged CLI on Unix - if: runner.os != 'Windows' - shell: bash - run: | - home_dir="$(mktemp -d)" - build/pyinstaller-dist/OneAgent/OneAgent --agent openclaw --check-agent-only --json --home "$home_dir" - - - name: Smoke packaged CLI on Windows - if: runner.os == 'Windows' - shell: pwsh - run: | - $homeDir = Join-Path $env:RUNNER_TEMP "oneagent-package-home" - ./build/pyinstaller-dist/OneAgent/OneAgent.exe --agent openclaw --check-agent-only --json --home $homeDir - - - name: Real macOS arm64 cleanroom - if: matrix.os == 'macos-15' - env: - ONEAGENT_PACKAGED_BINARY: ${{ github.workspace }}/build/pyinstaller-dist/OneAgent/OneAgent - run: bash tests/macos_cleanroom_test.sh - - - name: Validate release contents - run: python scripts/check_release.py release - - - name: Upload native release files - uses: actions/upload-artifact@v4 - with: - name: OneAgent-${{ matrix.os }}-technical-preview-unsigned - path: release/* - if-no-files-found: error - - - name: Upload cleanroom evidence - if: always() && matrix.os == 'macos-15' - uses: actions/upload-artifact@v4 - with: - name: macos-arm64-cleanroom-evidence - path: build/macos-cleanroom - if-no-files-found: ignore - - assemble: - name: Assemble release and public site - needs: build - runs-on: ubuntu-22.04 - outputs: - page_artifact_uploaded: ${{ steps.page_condition.outputs.uploaded }} - - steps: - - uses: actions/checkout@v4 - - - name: Download all native release files - uses: actions/download-artifact@v4 - with: - pattern: OneAgent-*-technical-preview-unsigned - path: build/native-release - merge-multiple: true - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: site/package-lock.json - - - name: Configure GitHub Pages metadata - if: github.event_name == 'workflow_dispatch' && inputs.deploy_pages - id: pages - uses: actions/configure-pages@v5 - - - name: Select immutable release bytes - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} - DEPLOY_PAGES: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_pages || false }} - shell: bash - run: | - rm -rf release - mkdir -p release - if [[ "$DEPLOY_PAGES" == "true" ]]; then - release_state="$(gh release view "$RELEASE_TAG" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')" - if [[ "$release_state" != $'false\ttrue' ]]; then - echo "Release $RELEASE_TAG must be a published prerelease before the website is deployed." >&2 - exit 1 - fi - gh release download "$RELEASE_TAG" --dir release - rm -f release/release-index.json - else - cp build/native-release/* release/ - fi - - - name: Install and test public site - working-directory: site - run: | - npm ci - npm test - - - name: Install Chromium for public site checks - working-directory: site - run: npx playwright install --with-deps chromium - - - name: Run public site browser and accessibility checks - working-directory: site - run: npm run test:e2e - - - name: Build verified public site - working-directory: site - env: - SITE_URL: ${{ steps.pages.outputs.origin || 'http://localhost:4321' }} - BASE_PATH: ${{ steps.pages.outputs.base_path || '/' }} - PUBLIC_SUPPORT_URL: ${{ vars.ONEAGENT_PUBLIC_SUPPORT_URL }} - PUBLIC_BUSINESS_EMAIL: ${{ vars.ONEAGENT_PUBLIC_BUSINESS_EMAIL }} - run: npm run build - - - name: Prepare public release assets - run: | - python scripts/build_release_index.py \ - --release-dir release \ - --channels distribution/channels.json \ - --output build/release-index.json \ - --copy-release-assets build/public-release - - - name: Create immutable draft prerelease - if: github.event_name == 'push' || inputs.create_draft_release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} - run: | - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - mkdir -p build/existing-release - gh release download "$RELEASE_TAG" --dir build/existing-release - python - <<'PY' - import hashlib - import os - from pathlib import Path - - expected = Path("build/public-release") - existing = Path("build/existing-release") - expected_files = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in expected.iterdir() if path.is_file()} - existing_files = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in existing.iterdir() if path.is_file()} - if expected_files != existing_files: - raise SystemExit( - f"Release {os.environ['RELEASE_TAG']} already exists with different assets; bump the version/tag instead of replacing bytes." - ) - print(f"Release {os.environ['RELEASE_TAG']} already contains the exact verified assets; nothing was replaced.") - PY - else - gh release create "$RELEASE_TAG" build/public-release/* \ - --draft \ - --prerelease \ - --target "$GITHUB_SHA" \ - --title "OneAgent $RELEASE_TAG" \ - --notes "Unsigned technical preview. Verify platform, architecture and SHA-256 before use." - fi - - - name: Mark Pages artifact condition - id: page_condition - shell: bash - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.deploy_pages }}" == "true" ]]; then - echo "uploaded=true" >> "$GITHUB_OUTPUT" - else - echo "uploaded=false" >> "$GITHUB_OUTPUT" - fi - - - name: Upload GitHub Pages artifact - if: steps.page_condition.outputs.uploaded == 'true' - uses: actions/upload-pages-artifact@v3 - with: - path: site/dist - - deploy-pages: - name: Deploy verified public site - needs: assemble - if: needs.assemble.outputs.page_artifact_uploaded == 'true' - runs-on: ubuntu-22.04 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 90350cc5..a95f913d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,17 @@ .DS_Store -__pycache__/ -*.py[cod] -.coverage -.venv/ -htmlcov/ -build/ -dist/ +# Anchored to the repository root. Written as a bare `dist/` it matched at any +# depth, and because excluding a directory stops git from descending into it, the +# `!frontend/dist/.keep` exception two lines down could never take effect — so +# the .keep that manifest_embed.go's `go:embed all:frontend/dist` needs was +# silently absent from every clone. +/dist/ release/ +# oneagent-release 的中间产物:notice、license 副本和打包暂存目录 +build/metadata/ +build/release-stage/ frontend/node_modules/ -frontend/dist/ +frontend/dist/* +!frontend/dist/.keep frontend/coverage/ frontend/test-results/ frontend/playwright-report/ @@ -17,9 +20,9 @@ frontend/playwright-report/ output/ .qoder/ -# 构建 wheel 时由 setup.py 暂存的运行时资源 -oneagent/_resources/ -*.egg-info/ +# Go CLI 与桌面壳的本地构建产物;scripts/install.* 只转发,不构建。 +bin/ + # 公开站点:构建缓存与测试产物 site/node_modules/ site/dist/ @@ -28,8 +31,4 @@ site/coverage/ site/test-results/ site/playwright-report/ -# 站点数据由 `npm run prepare:data` 从 agents.lock.json、distribution/ 与 -# release/ 重新生成。提交它们会让每次构建都产生 diff,而 release-index 还会 -# 把只有本机才有的产物校验和写进仓库。 -site/src/generated/ -site/public/downloads/ +.task diff --git a/CLAUDE.md b/CLAUDE.md index fd5f9628..48e6a98a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,92 +1,64 @@ -# CLAUDE.md +# OneAgent 开发约定 -OneAgent:本地 AI 开发环境激活器。Python 3.12 标准库内核 + React 七页向导,经 `127.0.0.1` HTTP 通信,负责检测、安装并配置 5 个 CLI Agent 指向 OpenAI- 或 Anthropic-compatible Provider。 +当前分支是 Go/Wails 收尾线,版本为 `0.3.0-dev`。桌面入口是 `cmd/oneagent-desktop`,headless CLI 是 `cmd/oneagent`;两者共享 `internal/` 用例。React 只通过生成的 Wails bindings 调用后端。 -当前 `0.2.0-dev`,发行渠道只能标记 `technical-preview-unsigned`。功能说明与发行流程见 [README.md](README.md)。 +## 目录 -## 沟通语言 +- `internal/app`:Status、Provider、Agent、Profile 用例和写入协调锁。 +- `internal/catalog`:嵌入的 `agents.lock.json`、`providers.lock.json`、`runtimes.lock.json` 与内置 Provider 目录。 +- `internal/config`:TOML/JSON/JSONC 适配器、配置发现和 golden fixtures。 +- `internal/install`:锁定包安装、registry/integrity 校验、Node.js/uv 运行时引导(下载、校验、解压、写入 PATH)和 Aider 外部前置条件。 +- `internal/profile`、`internal/securefs`:profile、secret、备份、权限和原子写。 +- `cmd/oneagent` +- `cmd/oneagent-release`:原生 Wails/Go/React 发布包、notice、manifest 和 SHA-256。 +- `cmd/oneagent-rc`、`cmd/oneagent-provider-smoke`:发行候选的真实 Agent/Provider 检查。 +- `frontend/bindings`:Wails 生成物,禁止手工编辑。 +- `site`:独立 Astro 公开站。 -**回复一律使用简体中文,没有例外。** 包括分析、结论、计划、代码审查意见和确认提问;用户用英文提问也不切换。代码、标识符、提交信息和代码注释保持英文(与现有代码库一致);README、`docs/` 和 ADR 保持中文。 +`providers.lock.json` 是内置 Provider 端点、fallback model 和公开站商业披露字段的真源;用户 Provider 与内置覆盖保存在 `~/.oneagent/providers.json`。 -## 常用命令 - -本机 `python3` 是 3.14,测试与打包必须显式用 `python3.12`;`scripts/gui.py` 用任意 ≥3.12 均可。 +## 本地命令 ```bash -# Python 契约测试(74 用例,约 7s) -python3.12 -m unittest tests.test_core tests.test_cli tests.test_server \ - tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts - -# 覆盖率门禁:整体 ≥85%,installer.py 必须 100% 分支且无 partial -python3.12 -m coverage run --branch -m unittest tests.test_core tests.test_cli \ - tests.test_server tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts -python3.12 -m coverage report --fail-under=85 - -# 源码 GUI -python3 scripts/gui.py --port 8765 --no-open - -# 前端(build 会先跑 tsc --noEmit) -cd frontend && npm ci && npm run build -npm run test:coverage -npm run e2e # Playwright 自动拉起 gui.py:8765 - -# 隔离验证 -bash tests/install_test.sh # install.sh 端到端,临时 HOME -python3.12 tests/gui_smoke_test.py # 真实 HTTP + Cookie/Origin 冒烟 -bash scripts/test_docker_cleanroom.sh # Linux 断网 cleanroom +go test ./... +go test -race ./... +go vet ./... +go build -o bin/oneagent ./cmd/oneagent + +cd frontend +npm ci +npm run test +npm run build +npm run test:e2e ``` -## 代码地图 +构建和检查发行包: +```bash +go run ./cmd/oneagent-release build --channel technical-preview-unsigned --source +go run ./cmd/oneagent-release check release ``` -oneagent/ Python 内核,零第三方依赖 - catalog.py 读 agents.lock.json、平台/HOME 解析、PROVIDERS 常量 - providers.py base URL 校验与推导、chat_probe、list_models - installer.py 主体:原子写、备份、权限、5 个配置适配器、install_many、status_payload - server.py stdlib http.server:/api/{status,probe,models,install,profiles,open-register}、 - POST /api/agents//activate(单 Agent 重新指向)+ 静态托管 - cli.py argparse CLI errors.py OneAgentError + 错误码→退出码 - entrypoint.py 打包版入口:无参→GUI,有参→CLI -frontend/src/ - App.tsx react-router 七页 + SetupGuard 前置校验 - state/ useReducer + Context,WizardState 是唯一状态源 - api/client.ts fetch 封装,非 2xx 抛 OneAgentApiError -agents.lock.json Agent 版本/包管理器/配置适配器/平台/许可证的唯一真源 -scripts/ gui.py、install.sh/.ps1、build_release.py、check_release.py -``` - -主链路:`React → POST /api/install → install_many() → _write_agent_config() → atomic_write()`。 - -## 硬性约束 -改动前先确认不会破坏以下条目,`tests/test_release_policy.py` 与 CI 会直接拦截: +真实 RC 只在受控环境运行: -- **零运行时依赖**:`oneagent/` 只用标准库,`pyproject.toml` 的 `dependencies` 保持为空。 -- **禁止 `shell=True` 与 `curl | sh`**:子进程一律走 `runtime.runner([...])` 列表参数。 -- **只绑定 127.0.0.1**:`create_server` 拒绝其他 host;POST 同时校验 Origin 白名单与 HttpOnly/SameSite=Strict 会话 Cookie(`secrets.compare_digest`)。 -- **API Key 不落地**:不进 `profile.json`、argv、URL、日志、React state、浏览器存储;日志一律过 `redact(text, [api_key])`。 -- **写配置只走 `atomic_write`**:`ensure_private_dir`(0700) → 备份 `*.backup-` → 临时文件先 `secure_path`(0600 / Windows icacls 断继承) → `os.replace`。密钥备份无法加固时删除并报错。 -- **保留用户字段**:Codex TOML 与 Claude/OpenCode/Kilo JSON 合并时不得丢弃非 OneAgent 管理的键;解析失败返回 `CONFIG_WRITE_FAILED`,绝不静默覆盖。 -- **版本锁定**:`agents.lock.json` 不允许 `latest`,npm 包必须带 `sha512-` integrity;`--latest` 仅在用户显式指定时生效。 -- **guide-only Agent** 不装包、不写私有配置、不起后台服务。 -- **前端产物**不得包含 source map 或 CDN/远程字体引用。 -- **覆盖率**:`oneagent/installer.py` 100% 分支且 `num_partial_branches == 0`;Python 整体与前端 `src/api`、`src/state` 均 ≥85%。 -- 产品边界(禁 VPN/代理/共享 Key/自动登录)见 [docs/product-boundary-baseline.md](docs/product-boundary-baseline.md),突破需新增 ADR。 - -## 代码约定 - -- Python 文件均以 `from __future__ import annotations` 开头,全量类型注解,dataclass 承载配置。 -- **副作用全部经 `Runtime`**:`home / os_id / runner / which / env` 都是可注入字段,测试靠替换它们模拟 npm、uv 与四个平台,不触碰真实系统。新增代码不要直接调用 `subprocess.run`、`shutil.which`、`os.environ` 或 `Path.home()`。 -- 失败一律 `raise OneAgentError(code, message)`,code 取自 `errors.EXIT_CODES`;响应恒定携带 `error / message / status / error_code / retryable`。 -- 传输契约:请求体 snake_case,`StatusResponse` 等派生字段 camelCase——改一侧必须同步 `frontend/src/types/api.ts`。 -- 常规 CI 使用假的 npm/uv,不下载真实 Agent、不访问 Provider;真实安装与 Provider 冒烟只在手动 `release-candidate.yml` 执行。 +```bash +go run ./cmd/oneagent-rc verify-agents +go run ./cmd/oneagent-rc adopted +go run ./cmd/oneagent-provider-smoke --provider all --timeout 30s +``` -## 常见任务 +普通测试、Wails 构建、站点构建和发布工具不需要 Python。安装 Aider 需要 Python 3.12,但不再要求本机预装:uv 自己解析解释器,本机有匹配版本就复用,否则下载一份托管 CPython 到 `~/.oneagent/runtimes/python`。Python 仍然不进发行包。 -**新增自动配置 Agent**:`agents.lock.json` 补条目(`command` / `config_path` / `config_adapter` / `credential_delivery` 必填,version/integrity/source/license/platforms 同旧)→ 在 `installer.py` 写 `write_*_config` 并注册到 `_write_agent_config` 分派 → 更新 `tests/test_core.py` 的适配器断言。 +## 代码边界 -`credential_delivery` 决定密钥怎么到达 Agent,取值 `oneagent_env`(配置文件引用 `ONEAGENT_*` 变量)、`native_env`(Agent 只读自己的变量名,需同时给 `env_vars`)、`config_file`(密钥在适配器写的配置里)。**启动命令、重启提示和 env 文件都由此推导,不要在 Python 里按 agent id 特判**——Claude Code 曾因为不在硬编码集合里而报「配置完成」却无法认证。 +- `agents.lock.json` 是 Agent 元数据唯一真源。新增自动配置 Agent 时先补 lock,再添加对应 config adapter 和 Go 测试。 +- 子进程必须使用 argv 数组和受控环境,设置超时并保留可诊断但已脱敏的输出。 +- 写入顺序必须是私有目录、备份、同目录临时文件、收紧权限、原子替换;密钥备份无法收紧时删除并失败。 +- 不把 API Key 写入普通 profile、状态摘要、日志、URL、全局 React state、浏览器存储或测试附件;仅 Provider 编辑/配置表单可通过本机 binding 按需读取私有存储中的 Key。 +- Provider 按 Agent 协议探测;`/v1/models` 不能替代 Responses、Anthropic Messages 或 Chat Completions 检查。 +- Wails 生产构建不能使用 `server` tag;浏览器 E2E 才能使用 server/e2e fake runner。 +- Linux 发行构建固定使用 `gtk3` tag,Wails Alpha 阶段只允许 `technical-preview-unsigned`。 -**新增 Provider**:`catalog.py` 的 `PROVIDERS` 加 `base_url` 与 `anthropic_base_url` → 同步 `frontend/src/types/api.ts` 的 `ProviderId`。 +## 文档维护 -**改错误码**:`errors.EXIT_CODES` 与 README「错误契约」小节必须同时更新。 +README、workflow、Taskfile、Dockerfile 和 AI Agent Kit 里的命令必须对应当前仓库文件。历史 ADR 可以保留背景,但必须明确标记为 Superseded,不能作为操作指南。 diff --git a/Dockerfile.test b/Dockerfile.test deleted file mode 100644 index e9b0174f..00000000 --- a/Dockerfile.test +++ /dev/null @@ -1,39 +0,0 @@ -FROM mcr.microsoft.com/playwright:v1.61.1-noble@sha256:5b8f294aff9041b7191c34a4bab3ac270157a28774d4b0660e9743297b697e48 - -USER root - -ENV DEBIAN_FRONTEND=noninteractive \ - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ - PATH=/opt/oneagent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin - -RUN set -eux; \ - sed -i 's|http://|https://|g' /etc/apt/sources.list.d/ubuntu.sources; \ - for attempt in 1 2 3 4 5; do \ - if apt-get -o Acquire::Retries=3 update; then break; fi; \ - if [ "$attempt" = "5" ]; then exit 1; fi; \ - rm -rf /var/lib/apt/lists/*; \ - sleep "$((attempt * 5))"; \ - done; \ - apt-get install -y --no-install-recommends \ - python3.12 \ - python3.12-venv \ - ripgrep; \ - rm -rf /var/lib/apt/lists/*; \ - python3.12 -m venv /opt/oneagent-venv; \ - /opt/oneagent-venv/bin/python -m pip install --no-cache-dir coverage==7.15.2 - -WORKDIR /opt/oneagent - -COPY --chown=pwuser:pwuser frontend/package.json frontend/package-lock.json ./frontend/ - -USER pwuser -RUN cd frontend && npm ci - -USER root -COPY --chown=pwuser:pwuser . . -RUN chown pwuser:pwuser /opt/oneagent \ - && chmod +x scripts/install.sh scripts/test_docker_cleanroom.sh scripts/run_container_cleanroom.sh tests/install_test.sh - -USER pwuser - -ENTRYPOINT ["bash", "scripts/run_container_cleanroom.sh"] diff --git a/README.md b/README.md index 9e5d3528..1fb72257 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,89 @@ # OneAgent -OneAgent 是一个本地 AI 开发环境激活器。它用 React 七页向导检测、安装并初始化常用 Agent,同时保留 Bash、PowerShell 和结构化 CLI。安装、备份、权限、Provider 探测和配置写入统一由 Python 3.12 核心完成。 +OneAgent 是一个本地 AI 开发环境激活器。React 向导和纯 Go CLI 共用同一套 Go 用例,负责检测 Agent、安装锁定版本、探测 Provider、合并配置、创建备份并收紧权限。桌面应用使用 Wails v3 binding;生产进程不监听业务 TCP 端口。 -OneAgent 不重新分发 Agent 二进制,不捆绑 Node.js、Python、Git Bash、VPN、代理、共享 API Key 或第三方配置工具。缺少前置工具时只返回明确错误和官方安装指引。 +OneAgent 不重新分发 Agent 包,也不捆绑 Node.js、系统 WebView、Git 或 API Key。缺少运行前置条件时返回明确错误和官方安装指引。 ## 当前状态 -当前版本为 `0.2.0-dev`,当前发行目标是可直接下载运行的 `technical-preview-unsigned` 二进制包。不以四平台同时分发作为产品阶段门槛;每个实际发布的平台仍须在对应操作系统原生构建,并以 CI cleanroom 作业作为验收证据。各平台最低目标见 [ADR-003](docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md)。 - -发行渠道不限定为 GitHub。官网、GitHub Release、网盘和企业云盘可以作为同一官方构建的镜像,但同一版本必须保持文件内容和 SHA-256 一致,渠道方不得重新打包或加入渠道专属内容。每个产物只声明实际构建和验证过的目标环境。 - -仍未取得证据的部分: - -- **真实 Agent 安装与真实 Provider 冒烟**:只在手动 `release-candidate.yml` 执行,需要受保护的 `ONEAGENT_PPIO_API_KEY`、`ONEAGENT_NOVITA_API_KEY` 与六个协议模型变量,尚未配置,因此尚未运行。 -- **Codex 的 Responses 协议**:PPIO/Novita 的 `/v1/responses` 仍未用真实 Key 验收;仅验证 Chat Completions 不能证明 Codex 可用。 - -当前阶段不处理平台商店、自动更新、macOS 公证或 Windows Authenticode,因此不使用 Stable 标签。Stable 门槛(macOS 签名/公证、Windows Authenticode)仍然有效并由 `scripts/build_release.py` 做产物级强制,当前只是不发布 Stable。完整包体、品牌、许可证、Key、渠道台账和跨渠道撤回规则见 [多渠道分发与合规政策](docs/distribution-compliance-policy.md)。 +当前版本为 `0.3.0-dev`,Wails 仍处于 Alpha,因此发布渠道只能是 `technical-preview-unsigned`。Python 迁移已经完成:受版本控制的旧实现、测试、PyInstaller/wheel 打包链路均已删除;普通构建、测试、运行和发布只需要 Go、Node(构建前端)及目标平台 WebView。Aider 是唯一例外:只有用户选择安装 Aider 时,才要求本机已有 Python 3.12,OneAgent 不会下载或管理它。 ## 架构 ```text React + TypeScript + Vite | - | localhost JSON API + | generated Wails bindings v -Python 3.12 HTTP Server +Status / Provider / Agent / Profile services | v -oneagent.installer - - 平台路径和前置检测 - - 锁定版本安装 - - 配置合并和备份 - - Unix mode / Windows ACL - - Provider 探测和结构化错误 -``` - -- `scripts/gui.py`:源码 GUI 入口。 -- `scripts/install.sh`:macOS/Linux CLI 转发层。 -- `scripts/install.ps1`:Windows CLI 转发层。 -- `oneagent/`:三平台共用安装核心、API Server 和 CLI。 -- `frontend/`:React 七页向导;发行包只携带构建后的 `dist`,终端用户不需要 Node.js。 -- `site/`:独立 Astro 静态公开站;不进入 Launcher 包体,也不复用本地路由和状态。 -- `distribution/`:公开渠道状态与 Provider 商业关系披露;技术排序与商业数据保持分离。 -- `agents.lock.json`:五个自动配置 Agent 的版本、包管理器、配置适配器、平台、来源和许可证锁定清单。 - -## 公开分发站 - -公开站从平台 manifest、SHA256SUMS、`agents.lock.json` 和 `distribution/` 配置生成下载与兼容目录,不手工复制版本或哈希: + Go application use cases + | + catalog / provider / install / config / profile / securefs -```bash -cd site -npm ci -npm test -npm run build -npx playwright install chromium -npm run test:e2e +Pure Go CLI --------------------^ Astro site ---- release metadata ``` -`npm run build` 会生成 `/release-index.json` 并校验公开 artifact 的大小与 SHA-256。`site/src/generated/` 与 `site/public/downloads/` 都由构建重新生成,不提交到 Git。GitHub Pages 子路径构建可设置 `SITE_URL` 与 `BASE_PATH`(该产物无法本地预览,原因见运营手册)。完整发布、镜像、Provider 披露和撤回流程见 [公开分发站运营与发布手册](docs/public-site-operations.md)。 +- `cmd/oneagent-desktop`:Wails 桌面入口。 +- `cmd/oneagent`:纯 Go headless CLI。 +- `cmd/oneagent-release`:构建、notice、manifest、SHA-256 和发行包检查。 +- `cmd/oneagent-rc`:真实锁定 Agent 安装和无密钥配置采用检查。 +- `cmd/oneagent-provider-smoke`:PPIO/Novita 三协议 RC smoke。 +- `internal/`:桌面、CLI 和 RC 工具共用的 Go 核心。 +- `frontend/`:React 应用;发行包只携带构建后的静态资源。 +- `site/`:独立 Astro 公开站,不进入桌面包体。 +- `agents.lock.json`:Agent 版本、来源、配置适配器和许可证的唯一清单。 +- `providers.lock.json`:内置 Provider 端点、fallback probe model 和公开站披露字段清单;桌面端用户 Provider 保存在本机 `~/.oneagent/providers.json`。 ## 快速启动 -### 源码 GUI - -源码运行要求 Python 3.12+。如需让 OneAgent 自动安装 Aider,还需要预先安装 `uv`;OneAgent 不会自动下载 Python: - -```bash -python3 scripts/gui.py -``` - -固定端口且不自动打开浏览器: - -```bash -python3 scripts/gui.py --port 8765 --no-open -``` - -GUI 只监听 `127.0.0.1`。首页设置随机 HttpOnly、SameSite=Strict 会话 Cookie,所有 POST 同时校验 Cookie 和 localhost Origin。 - -### 打包版 - -解压对应平台的 onedir 压缩包后运行: +### 桌面应用 ```bash -./OneAgent/OneAgent -``` - -Windows: - -```powershell -.\OneAgent\OneAgent.exe -``` - -未签名预览版不是 Stable。OneAgent 不提供绕过操作系统安全策略的指令。 - -### Python 包安装 - -OneAgent 内核只依赖标准库,可以直接作为 Python 包安装。已有 Python 3.12+ 的用户走这条路**不涉及 onedir 压缩包**,因此不触发 macOS Gatekeeper 或 Windows SmartScreen 对下载可执行文件的拦截: - -```bash -uv tool install ./OneAgent-0.2.0.dev0-py3-none-any.whl -# 或 pipx install ./OneAgent-0.2.0.dev0-py3-none-any.whl -``` - -安装后提供两个入口: - -```bash -oneagent --agent codex --check-agent-only # CLI -oneagent-gui --port 8765 --no-open # 本地 GUI -``` - -wheel 会把 `agents.lock.json` 与构建后的 `frontend/dist` 一并打进包内(见 `setup.py`),因此安装后无需仓库即可运行。本地构建 wheel: - -```bash -python3.12 -m pip wheel . --no-deps -w dist +cd frontend +npm ci +npm run build +cd .. +go run -tags wails ./cmd/oneagent-desktop ``` -尚未发布到 PyPI,目前只能从本地或 Release 附件安装。 +生产构建需要目标平台的 Wails/WebView 依赖。Linux 当前使用 `gtk3` tag(Ubuntu 22.04 cleanroom);macOS 使用系统 WKWebView;Windows 使用 WebView2 Runtime。 ### CLI -macOS/Linux: - ```bash -ONEAGENT_API_KEY="$MY_API_KEY" \ -./scripts/install.sh \ - --agent codex \ - --provider ppio \ - --model your-model-id \ - --channel direct +go build -o bin/oneagent ./cmd/oneagent ``` Windows PowerShell: ```powershell +go build -o bin\\oneagent.exe .\\cmd\\oneagent $env:ONEAGENT_API_KEY = $MyApiKey -.\scripts\install.ps1 --agent codex --provider ppio --model your-model-id +.\\scripts\\install.ps1 --agent codex --provider ppio --model your-model-id ``` -`--api-key` 仅为旧参数兼容和受控测试保留。日常使用应通过 GUI、交互粘贴或 `ONEAGENT_API_KEY` 传入,避免进入 shell history。 +日常使用优先通过 `ONEAGENT_API_KEY`、桌面粘贴或已保存 profile 传递凭据;`--api-key` 仅保留给受控脚本。 `--registry` 默认是官方 npm registry,镜像必须显式选择并使用 HTTPS。 -只检测或安装 Agent、不写模型配置: +### 公开站 ```bash -./scripts/install.sh --agent codex --check-agent-only -``` - -显式安装锁定版本: - -```bash -./scripts/install.sh --agent codex --check-agent-only --install-agent --locked-version -``` - -`--latest` 只能由用户显式选择,默认安装与发布测试均使用 `agents.lock.json` 的锁定版本。安装前会用 `npm view` 取 registry 声明的 `dist.integrity` 与锁定清单比对,不一致或版本不存在时拒绝安装,不会退到其他版本。 - -官方 npm registry 不可达时可显式指定授权镜像: - -```bash -./scripts/install.sh --agent codex --install-agent --locked-version --registry npmmirror -``` - -`--registry` 接受镜像 id(`official`、`npmmirror`)或 `https://` URL,只允许 HTTPS 且不得携带凭据。**默认始终是官方源**,镜像永远是显式选择,不会因网络失败自动切换——否则用户无法得知包的来源。实际使用的 registry 会记入安装日志。镜像只能是第三方公开 registry,OneAgent 不托管、不重打包任何 Agent 包体。 - -## GUI 流程 - -1. 多选 Agent,并查看本机安装、配置和前置条件状态。 -2. 选择配置模型服务,或使用官方账号/已有本地配置。 -3. 选择 PPIO、Novita 或 Custom,填写 Key 并测试连接。 -4. 请求 `GET /v1/models`;失败时手动输入模型 ID。 -5. 确认 Agent、写入路径、备份策略和 guide-only 项目。 -6. 同步执行安装与配置,按 Agent 返回最终状态;不伪造百分比。 -7. 从不含 Key 的 `~/.oneagent/profile.json` 恢复环境总览。 - -## Provider - -| Provider | 官网 | OpenAI-compatible base | Anthropic-compatible base | -| --- | --- | --- | --- | -| PPIO | `https://ppio.com/` | `https://api.ppio.com/openai` | `https://api.ppio.com/anthropic` | -| Novita | `https://novita.ai/` | `https://api.novita.ai/openai` | `https://api.novita.ai/anthropic` | - -连接测试使用: - -```text -POST /v1/chat/completions -GET /v1/models +cd site +npm ci +npm test +npm run build +npx playwright install chromium +npm run test:e2e ``` -Custom 支持 HTTP/HTTPS,包括用户主动配置的本机地址;拒绝 URL 凭据、非法 scheme 和控制字符。推荐显式使用 `--provider custom --api-base-url ...`。为兼容旧 CLI,合法 Provider 也可以用 `--api-base-url` 做显式覆盖。 - -### 按 Agent 协议验证 - -每个 Agent 配置后实际使用的推理协议不同,OneAgent 按此逐一验证,而不是统一探测 Chat Completions: - -| Agent | 协议 | 验证请求 | -| --- | --- | --- | -| Codex | Responses | `POST /v1/responses` | -| Claude Code | Anthropic Messages | `POST /v1/messages` | -| OpenCode、Kilo CLI、Aider | OpenAI-compatible | `POST /v1/chat/completions` | - -**同一个模型 ID 不一定同时兼容三种协议**,这不是理论风险。对一个 OpenAI-compatible 中转端点的 36 个文本模型实测: - -| 协议 | 通过 | -| --- | --- | -| Chat Completions | 31 / 36 | -| Anthropic Messages | 23 / 36 | -| **Responses** | **10 / 36** | - -在 30 个能明确判定的模型里只有 10 个支持 Responses,判定依据是端点显式回复 `400 "does not support endpoint: responses"` 或 `500 "not implemented"`。因此: +站点只读取 GitHub Release、`agents.lock.json` 和 `providers.lock.json`,不读取本地 `release/`,也不依赖桌面构建环境。 -- 连接测试会带上所选 Agent,逐个协议验证;只验证 Chat Completions 不能证明 Codex 可用。 -- 探测到协议不兼容时返回 `PROTOCOL_UNSUPPORTED` 并**拒绝写入配置**,不会把失败推迟到 Agent 首次请求。 -- 该错误不可重试;配额超限、上游过载等瞬时故障仍按可重试处理。 -- Claude Code 使用 Provider 的 Anthropic-compatible base,并写入 `ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_MODEL` 和 `ANTHROPIC_SMALL_FAST_MODEL`。 -- 真实 Agent 首次请求仍是发布门禁,不以 `/v1/models` 成功代替。 +## Agent 与 Provider -## Agent 范围 +自动配置 Agent: -### 自动配置 - -| Agent | 锁定版本 | 安装器 | 配置协议 | +| Agent | 锁定版本 | 安装器 | 协议 | | --- | --- | --- | --- | | Codex | `0.145.0` | npm | Responses | | Claude Code | `2.1.217` | npm | Anthropic Messages | @@ -225,214 +91,83 @@ Custom 支持 HTTP/HTTPS,包括用户主动配置的本机地址;拒绝 URL | Kilo CLI | `7.4.11` | npm | OpenAI-compatible | | Aider | `0.86.2` | uv tool | OpenAI-compatible | -### 只做引导 - -- 网关型:OpenClaw、Hermes。 -- 官方账号/平台型:Cursor、Kiro、Gemini CLI。 -- IDE 扩展型:Cline、Continue、Qwen Code、Kilo VS Code。 - -guide-only Agent 不执行包管理器安装,不写私有配置,不启动 daemon、gateway、WSL 或后台服务。 - -这与许可证无关:OpenClaw 与 Hermes 都是 MIT,分发上没有障碍。真正的原因是形态——两者以常驻网关方式运行,而统一网关被 [ADR-002](docs/decisions/ADR-002-product-boundary-and-network-access.md) 与 [ADR-003](docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md) 明确划在当前范围之外;Cursor 与 Kiro 以应用形式安装并通过自有账号体系登录。 - -### 界面顺序与可配置性无关 - -总览按 `agents.lock.json` 的 `rank` 字段排序,依据是该 Agent 的实际使用广度,而不是 OneAgent 能否配置它。因此 Cursor、OpenClaw、Hermes 与 Codex、Claude Code、OpenCode 并列在首屏,Kilo CLI 与 Aider 收进折叠区。guide-only 条目使用同样的行式呈现,只是操作为「官方文档」而非配置表单——把常用工具藏进脚注会让总览无法反映这台机器的真实情况。 - -Aider 使用隔离的 `uv tool install --python python3.12 --no-python-downloads`,不再调用系统级 pip。缺少 `uv` 或本机 Python 3.12 时返回 `PREREQUISITE_MISSING`,不会绕过 externally-managed Python,也不会自动安装语言运行时。 - -## 配置与备份 - -| Agent/状态 | 写入路径 | -| --- | --- | -| Codex | `~/.codex/config.toml`、`~/.oneagent/agents/codex.env` | -| Claude Code | `~/.claude/settings.json`、`~/.oneagent/agents/claude-code.env` | -| OpenCode | `~/.config/opencode/opencode.jsonc`、`~/.oneagent/agents/opencode.env` | -| Kilo CLI | `~/.config/kilo/kilo.jsonc`、`~/.oneagent/agents/kilo-cli.env` | -| Aider | `~/.oneagent/aider.env` 或 Windows `aider.ps1` | -| 环境摘要 | `~/.oneagent/profile.json` | +OpenClaw、Hermes、Cursor、Kiro、Gemini CLI、Cline、Continue、Qwen Code 和 Kilo VS Code 仅提供官方安装引导,不安装包、不写私有配置、不启动后台服务。 -凭据如何到达各 Agent 由 `agents.lock.json` 的 `credential_delivery` 声明,共三种: +内置 PPIO、Novita,并支持在 Provider 页面增删改用户 Provider。配置后按 Agent 实际协议探测:Codex 使用 `/v1/responses`,Claude Code 使用 `/v1/messages`,其余自动配置 Agent 使用 `/v1/chat/completions`。协议不兼容时返回 `PROTOCOL_UNSUPPORTED`,不会先写入不可用配置。 -- **`oneagent_env`**(Codex、OpenCode、Kilo CLI)——配置文件通过 `env_key` 或 `{env:...}` 引用 `ONEAGENT_API_KEY_CODEX` 这类专属变量,因此三者可同时指向不同 Provider。 -- **`native_env`**(Claude Code)——它只读自己定义的变量名,`env_vars` 声明为 `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL` / `ANTHROPIC_SMALL_FAST_MODEL`。**只写 `settings.json` 不足以让它认证**——它会忽略其中的凭据并报 `Not logged in`,所以 env 文件同时导出这四个原生变量。 -- **`config_file`**(Aider)——配置本身就是一个导出变量的 shell 脚本,无需额外 env 文件。 - -因此除 Aider 外,每个 Agent 的启动命令都会先 source 自己的 env 文件;`~/.oneagent/env` 仍写入共享的 `ONEAGENT_API_KEY`,供旧版本写下的配置继续使用。 - -Codex TOML、Claude/OpenCode/Kilo JSON 会保留非 OneAgent 管理字段。写入前创建 `*.backup-`;损坏配置返回 `CONFIG_WRITE_FAILED`,不会静默覆盖。 - -API Key 只进入本地密钥配置,不进入 `profile.json`、`agents/.json`、命令行、URL、日志、React reducer、浏览器存储或遥测。Unix 私有目录使用 `0700`、密钥文件和备份使用 `0600`;Windows 关闭 ACL 继承,仅允许当前用户和 SYSTEM。权限设置失败会终止发布写入。 - -## 按 Agent 管理 - -每个 Agent 独立记录自己指向的 Provider 与模型,互不影响:绑定写入 `~/.oneagent/agents/.json`(不含 Key),凭据写入同名 `.env`。因此 Codex 可以用 PPIO,同时 OpenCode 用 Novita。 - -```bash -oneagent agent list # 每个 Agent 当前的 Provider 与模型 -oneagent agent set codex --provider ppio --model deepseek/deepseek-v3 --api-key -oneagent agent set opencode --provider novita --model --profile team -``` - -`--profile` 复用 `profiles/` 里已保存模板的 Key,无需重新粘贴。GUI 对应 `POST /api/agents//activate`。 - -Agent 在启动时读取配置,因此重新指向后必须重启该 Agent 进程才会生效;响应与 CLI 输出都会给出对应的重启指引。切换只影响单个 Agent,失败不会波及其他 Agent。 - -## 错误契约 - -CLI `--json` 和本地 API 使用稳定错误码: - -- `INVALID_REQUEST` -- `INVALID_ORIGIN` -- `PREREQUISITE_MISSING` -- `API_KEY_REJECTED` -- `PROVIDER_UNREACHABLE` -- `MODELS_UNSUPPORTED` -- `PROTOCOL_UNSUPPORTED` -- `AGENT_INSTALL_FAILED` -- `CONFIG_WRITE_FAILED` -- `TIMEOUT` - -错误响应保留 `error`、`message`、`status`,并提供 `error_code` 和 `retryable`。 +Aider 的安装命令由 Go 后端固定为 `uv tool install --force --python python3.12 --no-python-downloads ...`。这条路径只在选择 Aider 时执行;缺少 `uv` 或 Python 3.12 会返回 `PREREQUISITE_MISSING`。 ## 开发与测试 -安装前端依赖并构建: +Go 核心、发行工具和 RC 工具: ```bash -cd frontend -npm ci -npm run build +go vet ./... +go test ./... +go test -race ./... +go run honnef.co/go/tools/cmd/staticcheck@2025.1.1 ./... +go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... ``` - -Python 3.12 契约和覆盖率: - -```bash -python3.12 -m coverage run --branch -m unittest \ - tests.test_core tests.test_cli tests.test_server \ - tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts -python3.12 -m coverage report --fail-under=85 -python3.12 -m coverage json -python3.12 -c "import json; s=json.load(open('build/coverage/coverage.json'))['files']['oneagent/installer.py']['summary']; assert s['percent_branches_covered'] == 100 and s['num_partial_branches'] == 0" -``` - -兼容测试: - -```bash -bash tests/install_test.sh -python3.12 tests/gui_smoke_test.py -``` - -React 与浏览器: +React/Wails 测试: ```bash cd frontend +npm ci npm run test:coverage npm run build npx playwright install chromium -npm run e2e +npm run test:e2e +cd .. +task test:native ``` -### Docker Linux Cleanroom -这是开发和 CI 的可选测试资产,不是 OneAgent 的发行环境,也不代表 macOS 或其他平台验收: +## Release Candidate -本地 Docker cleanroom 会构建测试专用镜像,再以非 root 用户、全新 HOME 和 `--network none` 执行 Python、Bash、GUI、React、Chromium E2E 与发行策略扫描: +真实锁定 Agent 安装(默认四个 npm Agent,不包含可选 Aider): ```bash -bash scripts/test_docker_cleanroom.sh +go build -o bin/oneagent ./cmd/oneagent +go run ./cmd/oneagent-rc verify-agents +go run ./cmd/oneagent-rc adopted ``` -镜像构建阶段允许下载 apt、pip 和 npm 锁定依赖;正式测试容器不挂载源码、Docker Socket 或用户 HOME,只把结果写入 `build/docker-cleanroom/`。镜像不包含五个 Agent、`uv`、Provider Key 或用户配置,也不会上传到镜像仓库。 - -Docker Desktop 在 macOS 上仍运行 Linux VM。该报告固定标记为 `linux`,只能证明 Linux cleanroom,不能替代 Darwin、APFS、`stat -f`、macOS PyInstaller、签名或公证验证。 - -### 空白机器可用性验证 - -常规套件都替换了 `Runtime.runner`,因此「Agent 真的装得上、装完真的能用」需要单独的三层验证。范围是 Codex 与 Claude Code,设计与结论见 [空白机器可用性验证计划](docs/blank-machine-verification-plan.md)。 +Provider 三协议 smoke 从受保护环境变量读取凭据和模型,不接受命令行 Key: ```bash -# 装包命令契约:离线、毫秒级,随常规 CI 运行 -python3.12 -m unittest tests.test_install_contract - -# 真实安装:干净 HOME + 隔离 npm 前缀,断言可执行文件落到 PATH、版本等于锁定版本 -bash tests/real_install_test.sh -ONEAGENT_REGISTRY=npmmirror bash tests/real_install_test.sh # 同样验证镜像路径 - -# 端到端可用性:需真实 Key,实际让两个 Agent 各回答一次请求 -ONEAGENT_API_KEY=... python3.12 scripts/agent_e2e_smoke.py --provider ppio +ONEAGENT_PPIO_API_KEY=... \ +ONEAGENT_PPIO_OPENAI_MODEL=... \ +ONEAGENT_PPIO_ANTHROPIC_MODEL=... \ +ONEAGENT_PPIO_RESPONSES_MODEL=... \ +ONEAGENT_NOVITA_API_KEY=... \ +ONEAGENT_NOVITA_OPENAI_MODEL=... \ +ONEAGENT_NOVITA_ANTHROPIC_MODEL=... \ +ONEAGENT_NOVITA_RESPONSES_MODEL=... \ +go run ./cmd/oneagent-provider-smoke --provider all --timeout 30s ``` -真实安装与端到端不进常规 CI:前者每次提交都会打 registry,后者需要真实凭据。二者分别由 `release-candidate.yml` 与人工在发行前执行。 - -### 真实 macOS Cleanroom - -真实 macOS cleanroom 是**已发布 macOS 产物**的验收依据,不是“必须先发布 macOS”的要求。当前架构的前端和 unsigned onedir 构建完成后,可以在真实 macOS 上运行: - -```bash -ONEAGENT_PACKAGED_BINARY="$PWD/build/pyinstaller-dist/OneAgent/OneAgent" \ -bash tests/macos_cleanroom_test.sh -``` - -脚本要求真实 `uname -s == Darwin`,使用 `env -i`、临时 HOME/TMPDIR 和受控 PATH,验证源码 GUI、打包 GUI、随机本地端口、Cookie/Origin、五个配置适配器、备份以及目录 `0700`/文件 `0600`。执行前后会比对真实用户配置目标,发现污染立即失败。 - -GitHub Actions 的 `ci.yml` macOS 作业(`macos-15` arm64 与 `macos-15-intel` x64)和手动 Release Candidate 运行该脚本,`tests/test_release_policy.py` 断言其契约不被弱化。普通 PR 与常规 CI 只使用 fake npm/uv,不下载真实 Agent,也不访问 PPIO/Novita;只有手动 Release Candidate 才在隔离 prefix/tool 目录中安装五个锁定版本并执行真实 Provider 冒烟。 - -覆盖门槛:安全、备份、配置写入、权限、脱敏和 manifest 校验逻辑要求 100% 分支覆盖;Python 核心与 React 状态/API 层整体分支覆盖不低于 85%。 - ## 发行 -本机生成未签名预览包: +在本机生成当前平台的未签名技术预览包: ```bash -python3.12 -m pip install pyinstaller==6.21.0 -python3.12 scripts/build_release.py \ +go run ./cmd/oneagent-release build \ --channel technical-preview-unsigned \ --source -python3.12 scripts/check_release.py release +go run ./cmd/oneagent-release check release ``` -产物包括: - -- 当前平台 PyInstaller onedir ZIP。 -- 可选源码 ZIP。 -- `release-manifest--.json`。 -- `SHA256SUMS--.txt`。 -- 第三方许可证和五个 Agent 的锁定版本清单。 - -PyInstaller 产物只声明其实际构建和验证过的目标环境。生成后的同一压缩包可以上传到 GitHub、官网、网盘或企业云盘;所有镜像必须保持相同 SHA-256,并记录渠道、链接、上传人、上传时间和撤回状态。 - -`.github/workflows/release-candidate.yml` 是定义中的真实验收门禁:四平台真实安装五个锁定 Agent,并使用受保护的 `ONEAGENT_PPIO_API_KEY`、`ONEAGENT_NOVITA_API_KEY` 与对应协议模型变量执行低 token 请求;缺少任一 Key 或协议模型 ID 时流程会失败,不会退化成假通过。在 CI Secret 配置完成前它尚未运行(见上文“仍未取得证据的部分”);在此之前,常规 CI 门禁以包体、许可证、secret、SHA-256、临时 HOME 启动和本地 Mock 流程为主。 +命令会构建 React、Wails 桌面二进制和纯 Go CLI,生成 macOS `.app` 或 Windows/Linux ZIP、可选源码 ZIP、第三方 notices、release manifest 和 SHA-256 清单。检查会拒绝 source map、远程资源、secret、Agent 二进制、语言运行时和不完整的锁定版本信息。 -在真实 PPIO/Novita 低权限 Key 尚未配置到受保护 CI Secret 前,可以使用 `apiproxy` 档案做本地三协议预检。该档案分别保留 OpenAI、Anthropic、Responses 三个模型槽位,当前统一使用 `openai/gpt-5.6-terra`;`openai/gpt-5.6-luna` 只支持两类协议,不作为三协议预检默认模型。 - -```bash -python3 scripts/provider_rc_smoke.py \ - --provider apiproxy \ - --api-key-json ~/.codex/auth.json \ - --api-key-field OPENAI_API_KEY \ - --timeout 45 -``` - -此命令只读取本机 JSON 中的 Key,不会把 Key 放入命令行值或输出。`--provider all` 仍严格只运行 PPIO 和 Novita;代理预检成功不能替代正式 RC 验收。详细边界见 [Provider RC 测试说明](docs/provider-rc-testing.md)。 - -当前只发布明确标记的 `technical-preview-unsigned`。当前阶段不做平台签名、公证和商店分发;Stable 门禁(macOS 签名/公证、Windows Authenticode)仍然有效并由 `scripts/build_release.py` 产物级强制,只是当前不走 Stable 渠道。 +Wails 仍处于 Alpha,当前不发布 Stable,不做平台签名、公证或商店分发。Stable 的签名门禁保留在后续发行阶段。 ## 文档 -- [产品边界基线](docs/product-boundary-baseline.md) -- [公开分发站运营与发布手册](docs/public-site-operations.md) -- [独立公开站与机器生成发行索引 ADR](docs/decisions/ADR-006-public-site-and-generated-release-index.md) -- [多渠道分发与合规政策](docs/distribution-compliance-policy.md) -- [渠道无关的二进制分发 ADR](docs/decisions/ADR-005-channel-neutral-distribution-and-compliance.md) -- [三平台 Python 内核与版本锁定 ADR](docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md) -- [按 Agent 协议验证 ADR](docs/decisions/ADR-004-per-agent-protocol-verification.md) -- [React 前端实现与发布门禁](docs/frontend-component-redesign-plan.md) +- [Wails v3 迁移收尾计划](docs/wails-v3-migration-plan.md) +- [发行与合规政策](docs/distribution-compliance-policy.md) +- [公开站运营手册](docs/public-site-operations.md) - [Provider RC 测试说明](docs/provider-rc-testing.md) -- [前端管理控制台改造计划](docs/frontend-management-console-plan.md) -- [CC Switch 参考笔记](docs/cc-switch-reference-notes.md) -- [用户使用文档](docs/ai-agent-kit/00-start-here.md) -- [配置工具选择](docs/ai-agent-kit/03-config-tools.md) -- [CC Switch 可选配置说明](docs/ai-agent-kit/tools/cc-switch.md) - -CC Switch 仅为可选文档,不自动安装、不进入运行依赖,也不替代 Provider API 服务。 +- [AI Agent Kit](docs/ai-agent-kit/00-start-here.md) +- [Wails 架构 ADR](docs/decisions/ADR-007-wails-v3-go-migration.md) +- [按 Agent 协议验证 ADR](docs/decisions/ADR-004-per-agent-protocol-verification.md) +- [历史 Python 发行 ADR(已废弃)](docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md) diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..a9c486a0 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,79 @@ +version: '3' + +vars: + APP_NAME: oneagent-desktop + BIN_DIR: bin + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + # Target OS for build/run. Defaults to the host OS; override with GOOS=... to + # cross-compile (only windows supports cross-compilation, see build/*/Taskfile.yml). + GOOS: '{{.GOOS | default OS}}' + WAILS_MODULE: github.com/wailsapp/wails/v3 + WAILS_VERSION: v3.0.0-beta.2 + # Pinned Wails CLI (see build/tool-versions.env); never rely on a global wails3. + WAILS3: go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} + +includes: + common: ./build/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + windows: ./build/windows/Taskfile.yml + +tasks: + build: + summary: Builds the desktop application for {{.GOOS}} + cmds: + - task: '{{.GOOS}}:build' + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + OUTPUT: '{{.OUTPUT}}' + + run: + summary: Runs the built desktop application + cmds: + - task: '{{.GOOS}}:run' + + dev: + summary: Runs the application in development mode (vite HMR + Go rebuild on change) + cmds: + - '{{.WAILS3}} dev -config ./build/config.yml -port {{.VITE_PORT}}' + + test:go: + summary: Run Go backend tests + cmds: + - go test ./... + + build:cli: + summary: Build the headless Go CLI + cmds: + - mkdir -p {{.BIN_DIR}} + - go build -o {{.BIN_DIR}}/oneagent ./cmd/oneagent + + generate:bindings: + summary: Regenerate Wails TypeScript bindings + cmds: + - task: common:generate:bindings + + build:frontend: + summary: Build the React frontend + cmds: + - task: common:build:frontend + + build:desktop: + summary: Build the production Wails desktop binary for the host OS + cmds: + - task: build + + test:e2e: + summary: Run the Wails server binding browser test + deps: + - build:frontend + cmds: + - cd frontend && pnpm run test:e2e + + test:native: + summary: Start the native Wails binary and require a GetStatus binding call + deps: + - build:desktop + cmds: + - go run ./cmd/oneagent-native-smoke -binary ./{{.BIN_DIR}}/{{.APP_NAME}} diff --git a/build/Taskfile.yml b/build/Taskfile.yml new file mode 100644 index 00000000..2eee4b7a --- /dev/null +++ b/build/Taskfile.yml @@ -0,0 +1,70 @@ +version: '3' + +tasks: + go:mod:tidy: + summary: Runs `go mod tidy` + internal: true + # darwin:build:universal runs its per-arch builds as parallel deps; two + # `go mod tidy` processes racing on go.mod can corrupt it. + run: once + cmds: + - go mod tidy + + install:frontend:deps: + summary: Install frontend dependencies (pnpm only) + run: once + dir: frontend + sources: + - package.json + - pnpm-lock.yaml + generates: + - node_modules + preconditions: + - sh: pnpm --version + msg: "pnpm not found. Install it first: https://pnpm.io/installation" + cmds: + - pnpm install + + build:frontend: + summary: Build the React frontend (tsc --noEmit runs as part of the build script) + # Universal builds run per-arch builds as parallel deps, each depending on + # this task; without run:once the two executions race on frontend/bindings. + run: once + dir: frontend + sources: + - "**/*" + - exclude: node_modules/**/* + generates: + - dist/**/* + deps: + - task: install:frontend:deps + - task: generate:bindings + cmds: + - pnpm run build + env: + PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}' + + generate:bindings: + summary: Regenerate Wails TypeScript bindings with the pinned Wails CLI + run: once + deps: + - task: go:mod:tidy + sources: + - "**/*.go" + - go.mod + - go.sum + - exclude: frontend/**/* + generates: + - frontend/bindings/**/* + cmds: + - '{{.WAILS3}} generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop' + + dev:frontend: + summary: Runs the Vite dev server (used by `task dev`) + deps: + - task: install:frontend:deps + dir: frontend + cmds: + # Vite 8 binds to [::1] only by default, but the Wails dev asset proxy + # dials localhost with forced IPv4 — bind 127.0.0.1 so they can meet. + - pnpm run dev --host 127.0.0.1 --port {{.VITE_PORT}} --strictPort diff --git a/build/config.yml b/build/config.yml new file mode 100644 index 00000000..a0d1ce0a --- /dev/null +++ b/build/config.yml @@ -0,0 +1,37 @@ +version: '3' + +info: + companyName: "MaimoryLab" + productName: "OneAgent" + productIdentifier: "com.maimorylab.oneagent" + description: "Local AI development environment activator" + copyright: "(c) 2026 MaimoryLab" + version: "0.3.0-dev" + +dev_mode: + root_path: . + log_level: warn + debounce: 500 + ignore: + dir: + - .git + - node_modules + - frontend + - bin + file: + - .DS_Store + - .gitkeep + - .keep + - "*_test.go" + watched_extension: + - "*.go" + git_ignore: true + executes: + # Dev build regenerates bindings and the frontend dist via task deps; the + # running app itself loads the frontend from the background Vite server. + - cmd: task build DEV=true + type: blocking + - cmd: task common:dev:frontend + type: background + - cmd: task run + type: primary diff --git a/build/darwin/Taskfile.yml b/build/darwin/Taskfile.yml new file mode 100644 index 00000000..8f9f8383 --- /dev/null +++ b/build/darwin/Taskfile.yml @@ -0,0 +1,48 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the macOS desktop binary (native only, no Docker cross-compilation) + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + DEV: + ref: .DEV + preconditions: + - sh: test "$(uname -s)" = "Darwin" + msg: "macOS binaries require CGO and must be built on macOS." + cmds: + - go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}" ./cmd/oneagent-desktop + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}-tags wails -buildvcs=false -gcflags=all="-l"{{else}}-tags wails,production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}' + OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}' + env: + GOOS: darwin + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + CGO_CFLAGS: "-mmacosx-version-min=12.0" + CGO_LDFLAGS: "-mmacosx-version-min=12.0" + MACOSX_DEPLOYMENT_TARGET: "12.0" + + build:universal: + summary: Builds a darwin universal binary (arm64 + amd64) + deps: + - task: build + vars: + ARCH: amd64 + OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}-amd64' + - task: build + vars: + ARCH: arm64 + OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}-arm64' + cmds: + - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + run: + cmds: + - '"{{.BIN_DIR}}/{{.APP_NAME}}"' diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml new file mode 100644 index 00000000..64c9bfa2 --- /dev/null +++ b/build/linux/Taskfile.yml @@ -0,0 +1,30 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the Linux desktop binary (native only, no Docker cross-compilation) + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + DEV: + ref: .DEV + preconditions: + - sh: test "$(uname -s)" = "Linux" + msg: "Linux binaries require CGO (WebKitGTK) and must be built on Linux." + cmds: + - go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}" ./cmd/oneagent-desktop + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}-tags wails -buildvcs=false -gcflags=all="-l"{{else}}-tags wails,production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}' + OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}' + env: + GOOS: linux + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + + run: + cmds: + - '"{{.BIN_DIR}}/{{.APP_NAME}}"' diff --git a/build/tool-versions.env b/build/tool-versions.env new file mode 100644 index 00000000..78a18e6a --- /dev/null +++ b/build/tool-versions.env @@ -0,0 +1,8 @@ +# Pinned migration toolchain. Update these values only after the native spike +# and binding-diff checks have been rerun on all four supported targets. +GO_VERSION=1.26 +NODE_VERSION=22 +WAILS_VERSION=v3.0.0-beta.2 +WAILS_CLI_VERSION=v3.0.0-beta.2 +WAILS_RUNTIME_VERSION=3.0.0-alpha2.117 +TASK_VERSION=v3.40.1-patched3 diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml new file mode 100644 index 00000000..444b38be --- /dev/null +++ b/build/windows/Taskfile.yml @@ -0,0 +1,27 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the Windows desktop binary (pure-Go cross-compile works from any host) + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + DEV: + ref: .DEV + cmds: + - go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}" ./cmd/oneagent-desktop + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}-tags wails -buildvcs=false -gcflags=all="-l"{{else}}-tags wails,production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}' + OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}' + env: + GOOS: windows + CGO_ENABLED: 0 + GOARCH: '{{.ARCH | default ARCH}}' + + run: + cmds: + - '"{{.BIN_DIR}}/{{.APP_NAME}}.exe"' diff --git a/cmd/oneagent-desktop/core.go b/cmd/oneagent-desktop/core.go new file mode 100644 index 00000000..c382e397 --- /dev/null +++ b/cmd/oneagent-desktop/core.go @@ -0,0 +1,9 @@ +//go:build wails && !e2e + +package main + +import "github.com/MaimoryLab/OneAgent/internal/app" + +func newDesktopUseCases() *app.UseCases { + return app.NewUseCasesFromEnvironment() +} diff --git a/cmd/oneagent-desktop/core_e2e.go b/cmd/oneagent-desktop/core_e2e.go new file mode 100644 index 00000000..b5eb0a9b --- /dev/null +++ b/cmd/oneagent-desktop/core_e2e.go @@ -0,0 +1,122 @@ +//go:build wails && e2e + +package main + +import ( + "context" + "io" + "net/http" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/MaimoryLab/OneAgent/internal/app" + "github.com/MaimoryLab/OneAgent/internal/catalog" + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +func newDesktopUseCases() *app.UseCases { + info := platform.Current() + home := platform.ResolveHome(nil, info.OS) + return app.NewUseCasesWithProviderClient(app.StatusOptions{ + Home: home, + Platform: info, + Runner: newE2ERunner(), + Environment: map[string]string{"HOME": home}, + }, provider.NewClient(e2eProviderDoer{})) +} + +type e2eRunner struct { + mu sync.RWMutex + agents map[string]catalog.Agent + byPackage map[string]string + installed map[string]string +} + +func newE2ERunner() *e2eRunner { + runner := &e2eRunner{ + agents: map[string]catalog.Agent{}, + byPackage: map[string]string{}, + installed: map[string]string{}, + } + manifest, err := catalog.LoadEmbedded() + if err != nil { + return runner + } + runner.agents = manifest.Agents + for id, agent := range manifest.Agents { + if agent.Package != nil { + runner.byPackage[agent.Package.Name+"@"+agent.Package.Version] = id + } + } + return runner +} + +func (r *e2eRunner) LookPath(command string) (string, bool) { + // The browser build pretends Node is present and uv is not, so the runtime + // section renders one installed row and one installable row without any + // download happening. + if command == "npm" || command == "node" { + return "/oneagent-e2e/" + command, true + } + r.mu.RLock() + _, ok := r.installed[command] + r.mu.RUnlock() + if !ok { + return "", false + } + return "/oneagent-e2e/" + command, true +} + +func (r *e2eRunner) Run(_ context.Context, argv []string, _ map[string]string, _ time.Duration) (process.Result, error) { + result := process.Result{Args: append([]string(nil), argv...), ExitCode: 0} + if len(argv) >= 4 && argv[1] == "view" && argv[3] == "dist.integrity" { + if id, ok := r.byPackage[argv[2]]; ok && r.agents[id].Package != nil && r.agents[id].Package.Integrity != nil { + result.Stdout = *r.agents[id].Package.Integrity + "\n" + return result, nil + } + result.ExitCode = 1 + return result, nil + } + if len(argv) >= 4 && argv[1] == "install" && argv[2] == "-g" { + if id, ok := r.byPackage[argv[3]]; ok { + agent := r.agents[id] + r.mu.Lock() + r.installed[agent.Command] = agent.Package.Version + r.mu.Unlock() + } + return result, nil + } + if len(argv) >= 2 && argv[len(argv)-1] == "--version" { + command := filepath.Base(argv[0]) + r.mu.RLock() + version := r.installed[command] + r.mu.RUnlock() + if version != "" { + result.Stdout = command + " " + version + "\n" + } + } + return result, nil +} + +type e2eProviderDoer struct{} + +func (e2eProviderDoer) Do(request *http.Request) (*http.Response, error) { + body := "" + if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/models") { + body = `{"data":[{"id":"oneagent-e2e-model"}]}` + } + status := http.StatusNoContent + if body != "" { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil +} diff --git a/cmd/oneagent-desktop/main.go b/cmd/oneagent-desktop/main.go new file mode 100644 index 00000000..35a7ff8b --- /dev/null +++ b/cmd/oneagent-desktop/main.go @@ -0,0 +1,12 @@ +//go:build !wails + +package main + +import "fmt" + +func main() { + // The default build is intentionally headless and dependency-free. Native + // Wails builds opt into the wails tag so Go tests and the CLI do not link a + // platform WebView by accident. + fmt.Println("OneAgent desktop shell: build with -tags wails") +} diff --git a/cmd/oneagent-desktop/main_wails.go b/cmd/oneagent-desktop/main_wails.go new file mode 100644 index 00000000..62aba7ae --- /dev/null +++ b/cmd/oneagent-desktop/main_wails.go @@ -0,0 +1,85 @@ +//go:build wails + +package main + +import ( + "log/slog" + "os" + "sync" + "time" + + oneagent "github.com/MaimoryLab/OneAgent" + "github.com/MaimoryLab/OneAgent/internal/binding" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/wailsapp/wails/v3/pkg/application" +) + +func main() { + var appInstance *application.App + core := newDesktopUseCases() + var nativeSmokeOnce sync.Once + var afterGetStatus func() + if os.Getenv("ONEAGENT_NATIVE_SMOKE") == "1" { + afterGetStatus = func() { + nativeSmokeOnce.Do(func() { + if result := os.Getenv("ONEAGENT_NATIVE_SMOKE_RESULT"); result != "" { + _ = os.WriteFile(result, []byte("ok\n"), 0o600) + } + time.AfterFunc(250*time.Millisecond, func() { + if appInstance != nil { + appInstance.Quit() + } + }) + }) + } + } + services := binding.NewServicesWithOptions(core, func(url string) error { + current := application.Get() + if current == nil || current.Browser == nil { + return oneerrors.New(oneerrors.InternalError, "Desktop browser is not ready") + } + return current.Browser.OpenURL(url) + }, binding.ServicesOptions{ + AfterGetStatus: afterGetStatus, + InstallOutput: func(output process.Output) { + if appInstance != nil { + appInstance.Event.Emit("oneagent:install-output", output) + } + }, + }) + + // No Route or RawMessageHandler is configured. The default Wails transport + // is internal IPC; the production app does not expose a business HTTP port. + appInstance = application.New(application.Options{ + Name: "OneAgent", + Description: "Local AI development environment activator", + LogLevel: slog.LevelInfo, + Services: []application.Service{ + application.NewServiceWithOptions(services.Status, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + application.NewServiceWithOptions(services.Provider, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + application.NewServiceWithOptions(services.Agent, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + application.NewServiceWithOptions(services.Profile, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + application.NewServiceWithOptions(services.Runtime, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + }, + MarshalError: oneerrors.Marshal, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(oneagent.FrontendAssets), + DisableLogging: true, + }, + Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}, + }) + if !application.System.IsServer() { + appInstance.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "OneAgent", + Width: 1180, + Height: 760, + URL: "/", + }) + } + if err := appInstance.Run(); err != nil { + // Do not print an arbitrary Wails error containing binding arguments. + _, _ = os.Stderr.WriteString("OneAgent desktop failed to start\n") + os.Exit(1) + } +} diff --git a/cmd/oneagent-native-smoke/main.go b/cmd/oneagent-native-smoke/main.go new file mode 100644 index 00000000..94c9530f --- /dev/null +++ b/cmd/oneagent-native-smoke/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +func main() { + binary := flag.String("binary", "", "path to the native OneAgent desktop binary") + timeout := flag.Duration("timeout", 20*time.Second, "maximum time to wait for GetStatus") + flag.Parse() + if err := run(*binary, *timeout); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(binary string, timeout time.Duration) error { + if binary == "" { + return fmt.Errorf("-binary is required") + } + if runtime.GOOS == "windows" && filepath.Ext(binary) == "" { + if _, err := os.Stat(binary + ".exe"); err == nil { + binary += ".exe" + } + } + home, err := os.MkdirTemp("", "oneagent-native-smoke-") + if err != nil { + return fmt.Errorf("create temporary HOME: %w", err) + } + defer os.RemoveAll(home) + result := filepath.Join(home, "get-status") + command := exec.Command(binary) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + command.Env = append(os.Environ(), + "HOME="+home, + "USERPROFILE="+home, + "ONEAGENT_HOME="+home, + "ONEAGENT_NATIVE_SMOKE=1", + "ONEAGENT_NATIVE_SMOKE_RESULT="+result, + ) + if err := command.Start(); err != nil { + return fmt.Errorf("start desktop app: %w", err) + } + exited := make(chan error, 1) + go func() { exited <- command.Wait() }() + select { + case err := <-exited: + if err != nil { + return fmt.Errorf("desktop app exited before GetStatus: %w", err) + } + case <-time.After(timeout): + _ = command.Process.Kill() + <-exited + return fmt.Errorf("timed out waiting for the desktop GetStatus binding") + } + if content, err := os.ReadFile(result); err != nil || string(content) != "ok\n" { + return fmt.Errorf("desktop app exited without calling GetStatus through the binding") + } + return nil +} diff --git a/cmd/oneagent-provider-smoke/main.go b/cmd/oneagent-provider-smoke/main.go new file mode 100644 index 00000000..d773a4bf --- /dev/null +++ b/cmd/oneagent-provider-smoke/main.go @@ -0,0 +1,91 @@ +// Command oneagent-provider-smoke runs the low-token checks for every Provider +// declared in providers.lock.json, as used by release-candidate verification. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +func main() { os.Exit(run(os.Args[1:])) } + +func run(args []string) int { + flags := flag.NewFlagSet("oneagent-provider-smoke", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + providerID := flags.String("provider", "all", "Provider ID or all") + timeout := flags.Duration("timeout", 30*time.Second, "per-request timeout") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + ids := catalog.ProviderIDs() + if *providerID != "all" { + ids = []string{*providerID} + } + client := provider.NewClientWithLimits(nil, *timeout, 1<<20) + for _, id := range ids { + if err := smoke(context.Background(), client, id, *timeout); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + } + return 0 +} + +func smoke(parent context.Context, client *provider.Client, id string, timeout time.Duration) error { + if _, ok := catalog.ProviderByID(id); !ok { + return fmt.Errorf("unknown Provider %q", id) + } + prefix := strings.ToUpper(id) + key := os.Getenv("ONEAGENT_" + prefix + "_API_KEY") + models := map[string]string{ + provider.ProtocolOpenAI: os.Getenv("ONEAGENT_" + prefix + "_OPENAI_MODEL"), + provider.ProtocolAnthropic: os.Getenv("ONEAGENT_" + prefix + "_ANTHROPIC_MODEL"), + provider.ProtocolResponses: os.Getenv("ONEAGENT_" + prefix + "_RESPONSES_MODEL"), + } + for protocolID, model := range models { + if strings.TrimSpace(model) == "" { + return fmt.Errorf("%s: ONEAGENT_%s_%s_MODEL is required", id, prefix, strings.ToUpper(protocolID)) + } + } + openAIBase := os.Getenv("ONEAGENT_" + prefix + "_OPENAI_BASE") + anthropicBase := os.Getenv("ONEAGENT_" + prefix + "_ANTHROPIC_BASE") + if key == "" { + return errors.New("ONEAGENT_" + prefix + "_API_KEY is required") + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + listing, err := client.ListModels(ctx, id, key, openAIBase) + if err != nil { + return fmt.Errorf("%s models: %w", id, err) + } + if !listing.Reachable || listing.Status < 200 || listing.Status >= 300 { + return fmt.Errorf("%s models: HTTP %d: %s", id, listing.Status, listing.Message) + } + fmt.Printf("%s models: HTTP %d\n", id, listing.Status) + for _, protocolID := range []string{provider.ProtocolOpenAI, provider.ProtocolResponses, provider.ProtocolAnthropic} { + base := openAIBase + if protocolID == provider.ProtocolAnthropic { + base = anthropicBase + } + result, probeErr := client.Probe(ctx, protocolID, id, key, models[protocolID], base) + if probeErr != nil { + return fmt.Errorf("%s %s: %w", id, protocolID, probeErr) + } + if !result.OK { + return fmt.Errorf("%s %s: HTTP %d: %s", id, protocolID, result.Status, result.Message) + } + fmt.Printf("%s %s: HTTP %d\n", id, protocolID, result.Status) + } + return nil +} diff --git a/cmd/oneagent-rc/main.go b/cmd/oneagent-rc/main.go new file mode 100644 index 00000000..f21a7327 --- /dev/null +++ b/cmd/oneagent-rc/main.go @@ -0,0 +1,434 @@ +// Command oneagent-rc contains the networked release-candidate checks that are +// intentionally kept outside the desktop and headless application binaries. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + "github.com/MaimoryLab/OneAgent/internal/install" +) + +var defaultAgents = []string{"codex", "claude-code", "opencode", "kilo-cli"} + +type isolation struct { + root string + home string + env []string + cli string +} + +type installResult struct { + Agent string `json:"agent"` + Status string `json:"status"` + Version string `json:"version"` +} + +type installPayload struct { + OK bool `json:"ok"` + Results []installResult `json:"results"` + Log string `json:"log"` +} + +func main() { os.Exit(run(os.Args[1:])) } + +func run(args []string) int { + if len(args) == 0 { + usage() + return 2 + } + root, err := repositoryRoot() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + switch args[0] { + case "verify-agents": + if err := verifyAgents(root, args[1:], false); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintln(os.Stderr, err) + return 1 + } + case "adopted": + if err := verifyAgents(root, args[1:], true); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintln(os.Stderr, err) + return 1 + } + default: + usage() + return 2 + } + return 0 +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: oneagent-rc verify-agents [--agents ids] [--registry id-or-url]") + fmt.Fprintln(os.Stderr, " oneagent-rc adopted [--agents codex,claude-code] [--timeout seconds]") +} + +func repositoryRoot() (string, error) { + directory, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil { + return directory, nil + } + parent := filepath.Dir(directory) + if parent == directory { + return "", errors.New("could not locate the OneAgent repository root") + } + directory = parent + } +} + +func verifyAgents(root string, args []string, adopted bool) error { + flags := flag.NewFlagSet("oneagent-rc", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + idsValue := strings.Join(defaultAgents, ",") + if adopted { + idsValue = "codex,claude-code" + } + ids := flags.String("agents", idsValue, "comma-separated Agent IDs") + registry := flags.String("registry", "", "npm mirror id or HTTPS URL") + timeout := flags.Int("timeout", 900, "seconds per package operation") + if err := flags.Parse(args); err != nil { + return err + } + agentIDs := splitIDs(*ids) + if len(agentIDs) == 0 { + return errors.New("at least one Agent is required") + } + manifest, err := catalog.LoadEmbedded() + if err != nil { + return err + } + for _, id := range agentIDs { + agent, ok := manifest.Agents[id] + if !ok || agent.ConfigMode != "auto" || agent.Package == nil { + return fmt.Errorf("%s is not an installable auto Agent", id) + } + if agent.Package.Manager != "npm" { + return fmt.Errorf("%s uses an optional package runtime; omit it from this check", id) + } + } + + isolated, cleanup, err := newIsolation(root) + if err != nil { + return err + } + defer cleanup() + ctx := context.Background() + installArgs := []string{ + "--agent", strings.Join(agentIDs, ","), + "--install-agent", "--locked-version", "--check-agent-only", "--json", + "--home", isolated.home, + } + if *registry != "" { + installArgs = append(installArgs, "--registry", *registry) + } + result, err := runCLI(ctx, isolated, installArgs...) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("locked Agent installation failed: %s", compact(result.Stdout+" "+result.Stderr)) + } + var payload installPayload + if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil { + return fmt.Errorf("decode OneAgent install result: %w", err) + } + if !payload.OK { + return fmt.Errorf("locked Agent installation reported failure: %s", compact(payload.Log)) + } + byID := make(map[string]installResult, len(payload.Results)) + for _, item := range payload.Results { + byID[item.Agent] = item + } + for _, id := range agentIDs { + agent := manifest.Agents[id] + item, ok := byID[id] + if !ok || item.Status != "installed" { + return fmt.Errorf("%s was not installed (status %q)", id, item.Status) + } + executable, ok := lookPath(isolated.env, agent.Command) + if !ok { + return fmt.Errorf("%s did not land on the isolated PATH", id) + } + versionValue, err := commandVersion(ctx, isolated.env, executable, agent.VersionArgs) + if err != nil { + return fmt.Errorf("read %s version: %w", id, err) + } + if versionValue != agent.Package.Version { + return fmt.Errorf("%s reports %s, lock requires %s", id, versionValue, agent.Package.Version) + } + fmt.Printf("%s: %s\n", id, versionValue) + } + if adopted { + return checkAdoption(ctx, isolated, agentIDs, time.Duration(*timeout)*time.Second) + } + return nil +} + +func checkAdoption(ctx context.Context, isolated isolation, agentIDs []string, timeout time.Duration) error { + const key = "oneagent-discard-key" + configure := []string{ + "--agent", strings.Join(agentIDs, ","), + "--provider", "custom", "--api-base-url", "http://127.0.0.1:9/openai", + "--api-key", key, "--model", "oneagent-discard-model", "--skip-test", "--json", + "--home", isolated.home, + } + result, err := runCLI(ctx, isolated, configure...) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("discard-endpoint configuration failed: %s", compact(result.Stdout+" "+result.Stderr)) + } + for _, id := range agentIDs { + command, argv, ok := adoptionCommand(id) + if !ok { + continue + } + executable, found := lookPath(isolated.env, command) + if !found { + return fmt.Errorf("%s is not on the isolated PATH", id) + } + env := append([]string(nil), isolated.env...) + env = appendEnv(env, "CI", "1") + env = appendEnv(env, "NO_COLOR", "1") + env = appendEnv(env, "TERM", "dumb") + env = appendEnv(env, "ONEAGENT_API_KEY_CODEX", key) + commandArgs := append([]string{executable}, argv...) + commandResult, runErr := runWithEnv(ctx, env, timeout, commandArgs...) + output := compact(commandResult.Stdout + " " + commandResult.Stderr) + if runErr != nil { + return fmt.Errorf("%s: %w", id, runErr) + } + adopted, reason := classifyAdoption(output) + if !adopted { + return fmt.Errorf("%s did not adopt its configuration: %s", id, reason) + } + fmt.Printf("%s: %s\n", id, reason) + } + return nil +} + +func adoptionCommand(id string) (string, []string, bool) { + switch id { + case "codex": + return "codex", []string{"exec", "Reply with the single word: ready"}, true + case "claude-code": + return "claude", []string{"-p", "Reply with the single word: ready"}, true + default: + return "", nil, false + } +} + +func classifyAdoption(output string) (bool, string) { + lowered := strings.ToLower(output) + for _, marker := range []string{"not logged in", "please run /login", "login required", "authentication required", "api key not found", "missing api key", "unauthorized", "no credentials"} { + if strings.Contains(lowered, marker) { + return false, "auth/login error (configuration was not adopted)" + } + } + for _, marker := range []string{"provider: oneagent", "reconnecting", "connection refused", "econnrefused", "could not connect", "failed to connect", "fetch failed", "network error", "unreachable", "os error 61", "os error 111"} { + if strings.Contains(lowered, marker) { + return true, "connection failure (configuration was adopted)" + } + } + return false, "output showed neither a connection failure nor an authentication error" +} + +func newIsolation(root string) (isolation, func(), error) { + directory, err := os.MkdirTemp("", "oneagent-rc-") + if err != nil { + return isolation{}, func() {}, err + } + home := filepath.Join(directory, "home") + prefix := filepath.Join(directory, "npm-prefix") + if err := os.MkdirAll(home, 0o700); err != nil { + os.RemoveAll(directory) + return isolation{}, func() {}, err + } + pathEntry := filepath.Join(prefix, "bin") + if runtime.GOOS == "windows" { + pathEntry = prefix + } + env := make([]string, 0) + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + upper := strings.ToUpper(name) + if strings.Contains(upper, "KEY") || strings.Contains(upper, "TOKEN") || strings.Contains(upper, "SECRET") || strings.Contains(upper, "PASSWORD") { + continue + } + if name == "NPM_CONFIG_USERCONFIG" || name == "npm_config_userconfig" || name == "UV_CONFIG_FILE" { + continue + } + env = append(env, entry) + } + pathValue := os.Getenv("PATH") + env = appendEnv(env, "HOME", home) + env = appendEnv(env, "USERPROFILE", home) + env = appendEnv(env, "ONEAGENT_HOME", home) + env = appendEnv(env, "NPM_CONFIG_PREFIX", prefix) + env = appendEnv(env, "npm_config_prefix", prefix) + env = appendEnv(env, "NPM_CONFIG_CACHE", filepath.Join(directory, "npm-cache")) + env = appendEnv(env, "NPM_CONFIG_USERCONFIG", filepath.Join(directory, "npmrc")) + env = appendEnv(env, "PATH", pathEntry+string(os.PathListSeparator)+pathValue) + cli, err := findCLI(root) + if err != nil { + os.RemoveAll(directory) + return isolation{}, func() {}, err + } + return isolation{root: directory, home: home, env: env, cli: cli}, func() { _ = os.RemoveAll(directory) }, nil +} + +func findCLI(root string) (string, error) { + if value := os.Getenv("ONEAGENT_CLI_BINARY"); value != "" { + if info, err := os.Stat(value); err == nil && !info.IsDir() { + return value, nil + } + } + names := []string{"oneagent"} + if runtime.GOOS == "windows" { + names = append([]string{"oneagent.exe"}, names...) + } + for _, name := range names { + candidate := filepath.Join(root, "bin", name) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + if value, err := exec.LookPath("oneagent"); err == nil { + return value, nil + } + return "", errors.New("build the Go CLI before running the RC checks") +} + +func runCLI(ctx context.Context, isolated isolation, args ...string) (execResult, error) { + return runWithEnv(ctx, isolated.env, 15*time.Minute, append([]string{isolated.cli}, args...)...) +} + +type execResult struct { + ExitCode int + Stdout string + Stderr string +} + +func runWithEnv(ctx context.Context, env []string, timeout time.Duration, argv ...string) (execResult, error) { + if len(argv) == 0 { + return execResult{}, errors.New("empty command") + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + command := exec.CommandContext(runCtx, argv[0], argv[1:]...) + command.Env = env + stdout, stderr := &strings.Builder{}, &strings.Builder{} + command.Stdout, command.Stderr = stdout, stderr + err := command.Run() + result := execResult{Stdout: stdout.String(), Stderr: stderr.String()} + if command.ProcessState != nil { + result.ExitCode = command.ProcessState.ExitCode() + } + if runCtx.Err() != nil { + return result, runCtx.Err() + } + if err != nil { + if _, ok := errors.AsType[*exec.ExitError](err); ok { + return result, nil + } + return result, err + } + return result, nil +} + +func commandVersion(ctx context.Context, env []string, executable string, args []string) (string, error) { + if len(args) == 0 { + args = []string{"--version"} + } + argv := append([]string{executable}, args...) + result, err := runWithEnv(ctx, env, 30*time.Second, argv...) + if err != nil { + return "", err + } + if result.ExitCode != 0 { + return "", fmt.Errorf("exit code %d: %s", result.ExitCode, compact(result.Stdout+" "+result.Stderr)) + } + value := install.VersionFromOutput(result.Stdout + "\n" + result.Stderr) + if value == "" { + return "", fmt.Errorf("no semantic version in %q", compact(result.Stdout+" "+result.Stderr)) + } + return value, nil +} + +func lookPath(env []string, command string) (string, bool) { + pathValue := "" + for _, entry := range env { + name, value, ok := strings.Cut(entry, "=") + if ok && name == "PATH" { + pathValue = value + break + } + } + for _, directory := range filepath.SplitList(pathValue) { + candidate := filepath.Join(directory, command) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + return candidate, true + } + if runtime.GOOS == "windows" { + for _, suffix := range []string{".exe", ".cmd", ".bat"} { + candidate = filepath.Join(directory, command+suffix) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, true + } + } + } + } + return "", false +} + +func appendEnv(values []string, name, value string) []string { + filtered := make([]string, 0, len(values)+1) + for _, entry := range values { + if key, _, ok := strings.Cut(entry, "="); !ok || key != name { + filtered = append(filtered, entry) + } + } + return append(filtered, name+"="+value) +} + +func splitIDs(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func compact(value string) string { + value = strings.Join(strings.Fields(value), " ") + if len(value) > 500 { + return value[len(value)-500:] + } + return value +} diff --git a/cmd/oneagent-release/main.go b/cmd/oneagent-release/main.go new file mode 100644 index 00000000..ec43475c --- /dev/null +++ b/cmd/oneagent-release/main.go @@ -0,0 +1,1031 @@ +// Command oneagent-release builds and validates the native Wails distribution. +// It deliberately uses only the Go and Node toolchains already required by the +// application; release output never embeds a language runtime. +package main + +import ( + "archive/zip" + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "runtime" + "slices" + "sort" + "strings" + "time" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + "github.com/MaimoryLab/OneAgent/internal/version" +) + +const previewChannel = "technical-preview-unsigned" + +type targetInfo struct { + OS string + Arch string +} + +type toolchainInfo struct { + Go string `json:"go"` + Wails string `json:"wails"` + Frontend string `json:"frontend"` +} + +type artifactInfo struct { + File string `json:"file"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` +} + +type releaseManifest struct { + SchemaVersion int `json:"schema_version"` + OneAgentVersion string `json:"oneagent_version"` + Channel string `json:"channel"` + Unsigned bool `json:"unsigned"` + Platform string `json:"platform"` + Arch string `json:"arch"` + Toolchain toolchainInfo `json:"toolchain"` + SystemWebView string `json:"system_webview"` + BuiltAt string `json:"built_at"` + AgentVersions map[string]string `json:"agent_versions"` + Artifacts []artifactInfo `json:"artifacts"` +} + +type packageLock struct { + Packages map[string]struct { + Version string `json:"version"` + License any `json:"license"` + Dev bool `json:"dev"` + } `json:"packages"` +} + +type moduleInfo struct { + Path string + Version string + Dir string + Main bool +} + +var ( + remoteAssetPattern = regexp.MustCompile(`(?i)(?:src|href)\s*=\s*["']https?://|@import\s+(?:url\()?\s*["']?https?://|url\(\s*["']?https?://`) + secretPattern = regexp.MustCompile(`(?i)sk-[A-Za-z0-9_-]{20,}|Bearer\s+[A-Za-z0-9._-]{24,}`) +) + +func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stderr) + return 2 + } + root, err := repositoryRoot() + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + switch args[0] { + case "build": + if err := buildRelease(root, args[1:], stdout, stderr); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 + case "check": + if err := checkRelease(commandPath(args[1:])); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + fmt.Fprintln(stdout, "release policy checks passed") + return 0 + default: + printUsage(stderr) + return 2 + } +} + +func printUsage(w io.Writer) { + fmt.Fprintln(w, "usage: oneagent-release build [--source] [--skip-frontend] [--channel technical-preview-unsigned]") + fmt.Fprintln(w, " oneagent-release check [release-directory]") +} + +func commandPath(args []string) string { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return "release" + } + return args[0] +} + +func repositoryRoot() (string, error) { + directory, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil { + return directory, nil + } + parent := filepath.Dir(directory) + if parent == directory { + return "", errors.New("could not locate the OneAgent repository root") + } + directory = parent + } +} + +func buildRelease(root string, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("build", flag.ContinueOnError) + flags.SetOutput(stderr) + channel := flags.String("channel", previewChannel, "release channel") + source := flags.Bool("source", false, "also create a source archive") + skipFrontend := flags.Bool("skip-frontend", false, "use an existing frontend/dist") + if err := flags.Parse(args); err != nil { + return err + } + if *channel != previewChannel { + return fmt.Errorf("only %q is publishable while Wails is Alpha", previewChannel) + } + target := currentTarget() + if err := ensureFrontend(root, *skipFrontend); err != nil { + return err + } + metadata := filepath.Join(root, "build", "metadata") + if err := os.RemoveAll(metadata); err != nil { + return fmt.Errorf("clear release metadata: %w", err) + } + if err := generateNotices(root, metadata); err != nil { + return err + } + stage, err := buildBinaries(root, target, metadata) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(root, "release"), 0o755); err != nil { + return err + } + artifactName := fmt.Sprintf("OneAgent-%s-%s-%s-%s.zip", version.Version, *channel, target.OS, target.Arch) + artifactPath := filepath.Join(root, "release", artifactName) + if err := zipDirectory(stage, artifactPath, "OneAgent"); err != nil { + return fmt.Errorf("create release archive: %w", err) + } + artifacts := []string{artifactPath} + if *source { + sourcePath := filepath.Join(root, fmt.Sprintf("release/OneAgent-%s-source.zip", version.Version)) + if err := zipSource(root, metadata, sourcePath, version.Version); err != nil { + return fmt.Errorf("create source archive: %w", err) + } + artifacts = append(artifacts, sourcePath) + } + manifestPath := filepath.Join(root, fmt.Sprintf("release/release-manifest-%s-%s.json", target.OS, target.Arch)) + manifest, err := makeManifest(root, target, *channel, artifacts) + if err != nil { + return err + } + manifestData, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + manifestData = append(manifestData, '\n') + if err := os.WriteFile(manifestPath, manifestData, 0o644); err != nil { + return fmt.Errorf("write release manifest: %w", err) + } + checksumPath := filepath.Join(root, fmt.Sprintf("release/SHA256SUMS-%s-%s.txt", target.OS, target.Arch)) + if err := writeChecksums(checksumPath, append(artifacts, manifestPath)); err != nil { + return err + } + fmt.Fprintln(stdout, artifactPath) + return nil +} + +func currentTarget() targetInfo { + osID := "linux" + switch runtime.GOOS { + case "darwin": + osID = "macos" + case "windows": + osID = "windows" + } + arch := "x64" + if runtime.GOARCH == "arm64" { + arch = "arm64" + } + return targetInfo{OS: osID, Arch: arch} +} + +func ensureFrontend(root string, skip bool) error { + dist := filepath.Join(root, "frontend", "dist") + if !skip { + npm, err := exec.LookPath("npm") + if err != nil { + return errors.New("npm is required to build the React frontend") + } + if err := runCommand(root, npm, "run", "build"); err != nil { + return fmt.Errorf("build frontend: %w", err) + } + } + if _, err := os.Stat(filepath.Join(dist, "index.html")); err != nil { + return errors.New("frontend/dist/index.html is missing; build the frontend first") + } + var maps []string + if err := filepath.WalkDir(dist, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() && strings.HasSuffix(strings.ToLower(path), ".map") { + maps = append(maps, path) + } + return nil + }); err != nil { + return err + } + if len(maps) > 0 { + return fmt.Errorf("source maps are forbidden in release assets: %s", maps[0]) + } + return nil +} + +func buildBinaries(root string, target targetInfo, metadata string) (string, error) { + stage := filepath.Join(root, "build", "release-stage", target.OS+"-"+target.Arch) + if err := os.RemoveAll(stage); err != nil { + return "", err + } + oneDir := filepath.Join(stage, "OneAgent") + if err := os.MkdirAll(oneDir, 0o755); err != nil { + return "", err + } + goTool, err := exec.LookPath("go") + if err != nil { + return "", errors.New("go is required to build the release") + } + desktop := filepath.Join(oneDir, "OneAgent") + desktopTags := "wails,production" + if target.OS == "linux" { + desktopTags += ",gtk3" + } + if target.OS == "macos" { + appDir := filepath.Join(oneDir, "OneAgent.app", "Contents", "MacOS") + if err := os.MkdirAll(appDir, 0o755); err != nil { + return "", err + } + desktop = filepath.Join(appDir, "OneAgent") + } + ldflags := "-w -s" + if target.OS == "windows" { + desktop = filepath.Join(oneDir, "OneAgent.exe") + ldflags += " -H windowsgui" + } + if err := runCommand(root, goTool, "build", "-tags", desktopTags, "-trimpath", "-buildvcs=false", "-ldflags="+ldflags, "-o", desktop, "./cmd/oneagent-desktop"); err != nil { + return "", fmt.Errorf("build desktop binary: %w", err) + } + cliName := "oneagent" + if target.OS == "windows" { + cliName += ".exe" + } + if err := runCommand(root, goTool, "build", "-trimpath", "-buildvcs=false", "-ldflags=-w -s", "-o", filepath.Join(oneDir, cliName), "./cmd/oneagent"); err != nil { + return "", fmt.Errorf("build CLI binary: %w", err) + } + if target.OS == "macos" { + plist := ` + + +CFBundleDisplayNameOneAgent +CFBundleExecutableOneAgent +CFBundleIdentifiercom.maimorylab.oneagent +CFBundleNameOneAgent +CFBundlePackageTypeAPPL +CFBundleShortVersionString0.3.0 +CFBundleVersion1 + +` + if err := os.WriteFile(filepath.Join(oneDir, "OneAgent.app", "Contents", "Info.plist"), []byte(plist), 0o644); err != nil { + return "", err + } + } + if err := copyFile(filepath.Join(root, "README.md"), filepath.Join(oneDir, "README.md")); err != nil { + return "", err + } + // Both locks ship so a reviewer can audit what the app would download — + // Agent packages and the runtimes that install them — without unpacking the + // binary. + for _, lock := range []string{"agents.lock.json", "runtimes.lock.json"} { + if err := copyFile(filepath.Join(root, lock), filepath.Join(oneDir, lock)); err != nil { + return "", err + } + } + if err := copyFile(filepath.Join(metadata, "THIRD_PARTY_NOTICES.md"), filepath.Join(oneDir, "THIRD_PARTY_NOTICES.md")); err != nil { + return "", err + } + if err := copyDir(filepath.Join(metadata, "licenses"), filepath.Join(oneDir, "licenses")); err != nil { + return "", err + } + return stage, nil +} + +func runCommand(dir, executable string, args ...string) error { + command := exec.Command(executable, args...) + command.Dir = dir + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Run() +} + +func makeManifest(root string, target targetInfo, channel string, files []string) (releaseManifest, error) { + lock, err := catalog.LoadEmbedded() + if err != nil { + return releaseManifest{}, err + } + frontendVersion := "unknown" + if data, readErr := os.ReadFile(filepath.Join(root, "frontend", "package.json")); readErr == nil { + var packageJSON struct { + Version string `json:"version"` + } + if json.Unmarshal(data, &packageJSON) == nil && packageJSON.Version != "" { + frontendVersion = packageJSON.Version + } + } + tools := readToolVersions(filepath.Join(root, "build", "tool-versions.env")) + agentVersions := make(map[string]string) + for id, agent := range lock.Agents { + if agent.Package != nil { + agentVersions[id] = agent.Package.Version + } + } + artifacts := make([]artifactInfo, 0, len(files)) + for _, file := range files { + info, err := os.Stat(file) + if err != nil { + return releaseManifest{}, err + } + digest, err := fileSHA256(file) + if err != nil { + return releaseManifest{}, err + } + artifacts = append(artifacts, artifactInfo{File: filepath.Base(file), SHA256: digest, Bytes: info.Size()}) + } + sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].File < artifacts[j].File }) + return releaseManifest{ + SchemaVersion: 2, OneAgentVersion: version.Version, Channel: channel, + Unsigned: true, Platform: target.OS, Arch: target.Arch, + Toolchain: toolchainInfo{Go: runtime.Version(), Wails: tools["WAILS_VERSION"], Frontend: frontendVersion}, + SystemWebView: webViewRequirement(target.OS), BuiltAt: time.Now().UTC().Format(time.RFC3339), + AgentVersions: agentVersions, Artifacts: artifacts, + }, nil +} + +func readToolVersions(file string) map[string]string { + values := map[string]string{} + data, err := os.ReadFile(file) + if err != nil { + return values + } + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if key, value, ok := strings.Cut(line, "="); ok { + values[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + return values +} + +func webViewRequirement(osID string) string { + switch osID { + case "macos": + return "WKWebView (macOS 12+)" + case "windows": + return "WebView2 Runtime" + default: + return "GTK3 + WebKitGTK 4.1" + } +} + +func writeChecksums(file string, paths []string) error { + sort.Slice(paths, func(i, j int) bool { return filepath.Base(paths[i]) < filepath.Base(paths[j]) }) + var builder strings.Builder + for _, path := range paths { + digest, err := fileSHA256(path) + if err != nil { + return err + } + fmt.Fprintf(&builder, "%s %s\n", digest, filepath.Base(path)) + } + return os.WriteFile(file, []byte(builder.String()), 0o644) +} + +func fileSHA256(file string) (string, error) { + hash := sha256.New() + handle, err := os.Open(file) + if err != nil { + return "", err + } + defer handle.Close() + if _, err := io.Copy(hash, handle); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func zipDirectory(source, destination, rootName string) error { + return createZip(destination, func(writer *zip.Writer) error { + return filepath.WalkDir(source, func(file string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(source, file) + if err != nil { + return err + } + return addZipFile(writer, file, path.Join(rootName, filepath.ToSlash(relative))) + }) + }) +} + +func zipSource(root, metadata, destination, versionValue string) error { + return createZip(destination, func(writer *zip.Writer) error { + command := exec.Command("git", "ls-files", "-co", "--exclude-standard", "-z") + command.Dir = root + output, err := command.Output() + if err != nil { + return fmt.Errorf("list source files: %w", err) + } + files := strings.SplitSeq(string(output), "\x00") + for relative := range files { + if relative == "" { + continue + } + if sourceArchiveExcluded(relative) { + continue + } + file := filepath.Join(root, filepath.FromSlash(relative)) + if info, statErr := os.Stat(file); statErr == nil && info.Mode().IsRegular() { + if err := addZipFile(writer, file, path.Join("OneAgent-"+versionValue, filepath.ToSlash(relative))); err != nil { + return err + } + } + } + for _, relative := range []string{"THIRD_PARTY_NOTICES.md", "licenses"} { + file := filepath.Join(metadata, relative) + if info, statErr := os.Stat(file); statErr == nil { + if info.IsDir() { + if err := addZipTree(writer, file, path.Join("OneAgent-"+versionValue, relative)); err != nil { + return err + } + } else if err := addZipFile(writer, file, path.Join("OneAgent-"+versionValue, relative)); err != nil { + return err + } + } + } + return nil + }) +} + +func sourceArchiveExcluded(relative string) bool { + relative = filepath.ToSlash(relative) + for _, prefix := range []string{"build/metadata/", "build/release-stage/", "release/", "frontend/dist/"} { + if strings.HasPrefix(relative, prefix) { + return true + } + } + return false +} + +func createZip(destination string, fill func(*zip.Writer) error) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + file, err := os.Create(destination) + if err != nil { + return err + } + writer := zip.NewWriter(file) + fillErr := fill(writer) + closeErr := writer.Close() + fileCloseErr := file.Close() + if fillErr != nil { + return fillErr + } + if closeErr != nil { + return closeErr + } + return fileCloseErr +} + +func addZipTree(writer *zip.Writer, source, rootName string) error { + return filepath.WalkDir(source, func(file string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(source, file) + if err != nil { + return err + } + return addZipFile(writer, file, path.Join(rootName, filepath.ToSlash(relative))) + }) +} + +func addZipFile(writer *zip.Writer, file, name string) error { + info, err := os.Stat(file) + if err != nil { + return err + } + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = filepath.ToSlash(name) + header.Method = zip.Deflate + header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC)) + entry, err := writer.CreateHeader(header) + if err != nil { + return err + } + input, err := os.Open(file) + if err != nil { + return err + } + defer input.Close() + _, err = io.Copy(entry, input) + return err +} + +func copyFile(source, destination string) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + return os.WriteFile(destination, data, 0o644) +} + +func copyDir(source, destination string) error { + if _, err := os.Stat(source); os.IsNotExist(err) { + return os.MkdirAll(destination, 0o755) + } + return filepath.WalkDir(source, func(file string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(source, file) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + if entry.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(file, target) + }) +} + +func generateNotices(root, output string) error { + if err := os.MkdirAll(filepath.Join(output, "licenses"), 0o755); err != nil { + return err + } + goPackages, err := goPackages(root) + if err != nil { + return err + } + frontendPackages, err := frontendPackages(root, filepath.Join(output, "licenses")) + if err != nil { + return err + } + manifest, err := catalog.LoadEmbedded() + if err != nil { + return err + } + runtimes, err := catalog.LoadEmbeddedRuntimes() + if err != nil { + return err + } + var builder strings.Builder + builder.WriteString("# OneAgent Third-Party Notices\n\n") + builder.WriteString("OneAgent bundles the Go application and its built React assets. Agent packages are not bundled; they are installed from the listed upstream source only after user confirmation.\n\n") + builder.WriteString("## Runtime Components\n\n") + builder.WriteString("- Go standard library: BSD-3-Clause, https://go.dev/LICENSE\n") + builder.WriteString("- Wails and Go modules: see the bundled `licenses/` files and module source URLs below.\n\n") + builder.WriteString("| Go module | Version | License file | Source |\n| --- | --- | --- | --- |\n") + for _, item := range goPackages { + fmt.Fprintf(&builder, "| `%s` | `%s` | `%s` | https://pkg.go.dev/%s@%s |\n", item.Name, item.Version, item.License, item.Name, item.Version) + } + builder.WriteString("\n## Frontend Runtime Packages\n\n| Package | Version | License | License file |\n| --- | --- | --- | --- |\n") + for _, item := range frontendPackages { + fmt.Fprintf(&builder, "| `%s` | `%s` | %s | %s |\n", item.Name, item.Version, item.License, item.LicenseFile) + } + builder.WriteString("\n## Agent Installation Targets (Not Bundled)\n\n| Agent | Locked package | License | Source | License reference |\n| --- | --- | --- | --- | --- |\n") + // Iterate in catalog order: a map walk would reorder the table on every run + // and change the notice file's own SHA-256. + for _, id := range catalog.AgentIDs(manifest) { + agent := manifest.Agents[id] + if agent.Package == nil { + continue + } + fmt.Fprintf(&builder, "| %s | `%s@%s` | %s | %s | %s |\n", agent.Name, agent.Package.Name, agent.Package.Version, agent.Package.License, agent.Package.Source, agent.Package.LicenseURL) + } + builder.WriteString("\n## Runtime Bootstrap Targets (Not Bundled)\n\nOneAgent can download these runtimes on request to provide the package managers Agents are installed with. Each download is pinned to the version and SHA-256 in `runtimes.lock.json` and is verified before use.\n\n| Runtime | Locked version | License | Source | License reference |\n| --- | --- | --- | --- | --- |\n") + for _, id := range runtimes.RuntimeOrder { + entry := runtimes.Runtimes[id] + fmt.Fprintf(&builder, "| %s | `%s` | %s | %s | %s |\n", entry.Name, entry.Version, entry.License, entry.Source, entry.LicenseURL) + } + return os.WriteFile(filepath.Join(output, "THIRD_PARTY_NOTICES.md"), []byte(builder.String()), 0o644) +} + +type noticeItem struct { + Name string + Version string + License string + LicenseFile string +} + +func goPackages(root string) ([]noticeItem, error) { + command := exec.Command("go", "list", "-m", "-json", "all") + command.Dir = root + output, err := command.Output() + if err != nil { + return nil, fmt.Errorf("list Go modules: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(output)) + items := []noticeItem{} + for { + var module moduleInfo + if err := decoder.Decode(&module); errors.Is(err, io.EOF) { + break + } else if err != nil { + return nil, fmt.Errorf("decode Go module list: %w", err) + } + if module.Main || module.Path == "" { + continue + } + license := "see bundled source" + licenseFile := "" + if module.Dir != "" { + licenseFile = copyLicense(module.Dir, filepath.Join(root, "build", "metadata", "licenses"), "go-"+module.Path) + } + if licenseFile != "" { + license = licenseFile + } + items = append(items, noticeItem{Name: module.Path, Version: module.Version, License: license, LicenseFile: licenseFile}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + return items, nil +} + +func frontendPackages(root, licenseDir string) ([]noticeItem, error) { + data, err := os.ReadFile(filepath.Join(root, "frontend", "package-lock.json")) + if err != nil { + return nil, err + } + var lock packageLock + if err := json.Unmarshal(data, &lock); err != nil { + return nil, fmt.Errorf("read frontend lock: %w", err) + } + items := []noticeItem{} + for relative, metadata := range lock.Packages { + if !strings.HasPrefix(relative, "node_modules/") || metadata.Dev || metadata.Version == "" { + continue + } + name := strings.TrimPrefix(relative, "node_modules/") + license := licenseValue(metadata.License) + licenseFile := "" + packageDir := filepath.Join(root, "frontend", relative) + if packageData, readErr := os.ReadFile(filepath.Join(packageDir, "package.json")); readErr == nil { + var packageJSON struct { + Name string `json:"name"` + License any `json:"license"` + } + if json.Unmarshal(packageData, &packageJSON) == nil { + if packageJSON.Name != "" { + name = packageJSON.Name + } + if packageJSON.License != nil { + license = licenseValue(packageJSON.License) + } + } + } + if copied := copyLicense(packageDir, licenseDir, "npm-"+name+"-"+metadata.Version); copied != "" { + licenseFile = copied + } + if license == "" { + license = "see package metadata" + } + if licenseFile == "" { + licenseFile = "not provided by package" + } + items = append(items, noticeItem{Name: name, Version: metadata.Version, License: license, LicenseFile: licenseFile}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + return items, nil +} + +func licenseValue(value any) string { + switch typed := value.(type) { + case string: + return typed + case map[string]any: + if name, ok := typed["type"].(string); ok { + return name + } + if name, ok := typed["name"].(string); ok { + return name + } + case []any: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + if value := licenseValue(item); value != "" { + parts = append(parts, value) + } + } + return strings.Join(parts, ", ") + } + return "" +} + +func copyLicense(source, destination, label string) string { + entries, err := os.ReadDir(source) + if err != nil { + return "" + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + lower := strings.ToLower(entry.Name()) + if !(strings.HasPrefix(lower, "license") || strings.HasPrefix(lower, "licence") || strings.HasPrefix(lower, "copying") || strings.HasPrefix(lower, "notice")) { + continue + } + if err := os.MkdirAll(destination, 0o755); err != nil { + return "" + } + name := safeName(label) + filepath.Ext(entry.Name()) + if err := copyFile(filepath.Join(source, entry.Name()), filepath.Join(destination, name)); err != nil { + return "" + } + return filepath.ToSlash(filepath.Join("licenses", name)) + } + return "" +} + +func safeName(value string) string { + var builder strings.Builder + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '.' || character == '-' || character == '_' { + builder.WriteRune(character) + } else { + builder.WriteByte('_') + } + } + return strings.Trim(builder.String(), "_") +} + +func checkRelease(directory string) error { + manifests, _ := filepath.Glob(filepath.Join(directory, "release-manifest-*.json")) + archives, _ := filepath.Glob(filepath.Join(directory, "OneAgent-*.zip")) + problems := []string{} + if len(manifests) == 0 { + problems = append(problems, "release manifest is missing") + } + if len(archives) == 0 { + problems = append(problems, "release archive is missing") + } + for _, manifest := range manifests { + problems = append(problems, validateManifest(manifest)...) + } + for _, archive := range archives { + problems = append(problems, inspectArchive(archive)...) + } + if len(problems) > 0 { + return errors.New(strings.Join(problems, "\n")) + } + return nil +} + +func validateManifest(file string) []string { + problems := []string{} + data, err := os.ReadFile(file) + if err != nil { + return []string{fmt.Sprintf("read manifest %s: %v", filepath.Base(file), err)} + } + var manifest releaseManifest + var raw map[string]any + if err := json.Unmarshal(data, &manifest); err != nil || json.Unmarshal(data, &raw) != nil { + return []string{fmt.Sprintf("invalid release manifest %s", filepath.Base(file))} + } + if manifest.SchemaVersion != 2 { + problems = append(problems, "unsupported release manifest schema: "+filepath.Base(file)) + } + if _, present := raw["python"]; present { + problems = append(problems, "release manifest contains a removed python field: "+filepath.Base(file)) + } + if manifest.Channel != previewChannel || !manifest.Unsigned { + problems = append(problems, "release manifest must be an unsigned technical preview: "+filepath.Base(file)) + } + if manifest.Toolchain.Go == "" || manifest.Toolchain.Wails == "" || manifest.Toolchain.Frontend == "" || manifest.SystemWebView == "" { + problems = append(problems, "release manifest toolchain or WebView requirement is incomplete: "+filepath.Base(file)) + } + if len(manifest.Artifacts) == 0 { + return append(problems, "artifact list is missing: "+filepath.Base(file)) + } + expectedVersions := manifest.AgentVersions + for _, artifact := range manifest.Artifacts { + if filepath.Base(artifact.File) != artifact.File { + problems = append(problems, "artifact filename contains a path: "+artifact.File) + continue + } + artifactPath := filepath.Join(filepath.Dir(file), artifact.File) + info, statErr := os.Stat(artifactPath) + if statErr != nil { + problems = append(problems, "manifest artifact is missing: "+artifact.File) + continue + } + if info.Size() != artifact.Bytes { + problems = append(problems, "artifact size mismatch: "+artifact.File) + } + if digest, hashErr := fileSHA256(artifactPath); hashErr != nil || digest != artifact.SHA256 { + problems = append(problems, "artifact checksum mismatch: "+artifact.File) + } + if !strings.Contains(artifact.File, "-source.zip") && !strings.Contains(artifact.File, manifest.Channel) { + problems = append(problems, "artifact channel mismatch: "+artifact.File) + } + if versions := archiveAgentVersions(artifactPath); versions == nil { + problems = append(problems, "lock manifest is missing or invalid: "+artifact.File) + } else if !mapsEqual(versions, expectedVersions) { + problems = append(problems, "locked Agent versions mismatch: "+artifact.File) + } + } + suffix := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(file), "release-manifest-"), ".json") + checksumFile := filepath.Join(filepath.Dir(file), "SHA256SUMS-"+suffix+".txt") + checksums := map[string]string{} + if checksumData, readErr := os.ReadFile(checksumFile); readErr != nil { + problems = append(problems, "checksum file is missing: "+filepath.Base(checksumFile)) + } else { + scanner := bufio.NewScanner(bytes.NewReader(checksumData)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 2 || len(fields[0]) != sha256.Size*2 { + problems = append(problems, "invalid checksum line in "+filepath.Base(checksumFile)) + continue + } + checksums[fields[1]] = fields[0] + } + for _, expected := range append([]string{filepath.Base(file)}, artifactNames(manifest.Artifacts)...) { + candidate := filepath.Join(filepath.Dir(file), expected) + if digest, hashErr := fileSHA256(candidate); hashErr == nil && checksums[expected] != digest { + problems = append(problems, "checksum file mismatch: "+expected) + } + } + } + return problems +} + +func artifactNames(artifacts []artifactInfo) []string { + names := make([]string, 0, len(artifacts)) + for _, artifact := range artifacts { + names = append(names, artifact.File) + } + return names +} + +func archiveAgentVersions(file string) map[string]string { + archive, err := zip.OpenReader(file) + if err != nil { + return nil + } + defer archive.Close() + var matches []*zip.File + for _, entry := range archive.File { + if path.Base(entry.Name) == "agents.lock.json" { + matches = append(matches, entry) + } + } + if len(matches) != 1 { + return nil + } + reader, err := matches[0].Open() + if err != nil { + return nil + } + defer reader.Close() + var manifest struct { + Agents map[string]struct { + Package *struct { + Version string `json:"version"` + } `json:"package"` + } `json:"agents"` + } + if json.NewDecoder(reader).Decode(&manifest) != nil { + return nil + } + result := map[string]string{} + for id, agent := range manifest.Agents { + if agent.Package != nil { + result[id] = agent.Package.Version + } + } + return result +} + +func mapsEqual(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} + +func inspectArchive(file string) []string { + problems := []string{} + archive, err := zip.OpenReader(file) + if err != nil { + return []string{fmt.Sprintf("cannot open %s: %v", filepath.Base(file), err)} + } + defer archive.Close() + required := map[string]bool{} + for _, entry := range archive.File { + name := filepath.ToSlash(entry.Name) + base := strings.ToLower(path.Base(name)) + parts := strings.Split(name, "/") + if strings.HasPrefix(name, "/") || slicesContain(parts, "..") { + problems = append(problems, "unsafe archive path in "+filepath.Base(file)+": "+name) + } + if strings.EqualFold(base, "agents.lock.json") { + required["agents.lock.json"] = true + } + if strings.EqualFold(base, "runtimes.lock.json") { + required["runtimes.lock.json"] = true + } + if strings.EqualFold(base, "THIRD_PARTY_NOTICES.md") { + required["THIRD_PARTY_NOTICES.md"] = true + } + lowerName := strings.ToLower(name) + for _, suffix := range []string{".py", ".pyc", ".pyo", ".pyd", ".whl"} { + if strings.HasSuffix(lowerName, suffix) { + problems = append(problems, "Python artifact in "+filepath.Base(file)+": "+name) + } + } + if strings.Contains(lowerName, "pyinstaller") || strings.Contains(base, "libpython") { + problems = append(problems, "Python runtime in "+filepath.Base(file)+": "+name) + } + if strings.HasSuffix(lowerName, ".map") { + problems = append(problems, "source map in "+filepath.Base(file)+": "+name) + } + if slicesContain([]string{"codex", "codex.exe", "claude", "claude.exe", "opencode", "opencode.exe", "kilo", "kilo.exe", "aider", "aider.exe"}, base) { + problems = append(problems, "Agent binary in "+filepath.Base(file)+": "+name) + } + if !entry.FileInfo().IsDir() && isTextArchiveFile(lowerName) { + reader, openErr := entry.Open() + if openErr != nil { + continue + } + data, readErr := io.ReadAll(io.LimitReader(reader, 8<<20)) + reader.Close() + if readErr == nil && secretPattern.Match(data) { + problems = append(problems, "possible secret in "+filepath.Base(file)+": "+name) + } + if readErr == nil && strings.Contains(lowerName, "/frontend/dist/") && remoteAssetPattern.Match(data) { + problems = append(problems, "remote asset reference in "+filepath.Base(file)+": "+name) + } + } + } + for _, name := range []string{"agents.lock.json", "runtimes.lock.json", "THIRD_PARTY_NOTICES.md"} { + if !required[name] { + problems = append(problems, "missing "+name+" in "+filepath.Base(file)) + } + } + return problems +} + +func isTextArchiveFile(name string) bool { + for _, suffix := range []string{".html", ".css", ".js", ".json", ".md", ".txt", ".toml", ".ps1", ".sh"} { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} + +func slicesContain(values []string, target string) bool { + return slices.Contains(values, target) +} diff --git a/cmd/oneagent/main.go b/cmd/oneagent/main.go new file mode 100644 index 00000000..20a0c967 --- /dev/null +++ b/cmd/oneagent/main.go @@ -0,0 +1,442 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "runtime" + "sort" + "strings" + "syscall" + "time" + + "github.com/MaimoryLab/OneAgent/internal/app" + "github.com/MaimoryLab/OneAgent/internal/catalog" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/process" + profileStore "github.com/MaimoryLab/OneAgent/internal/profile" + "github.com/MaimoryLab/OneAgent/internal/provider" + "github.com/MaimoryLab/OneAgent/internal/version" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 || helpRequested(args) { + printUsage(stdout) + return 0 + } + switch args[0] { + case "--version", "-version", "version": + _, _ = fmt.Fprintln(stdout, version.Version) + return 0 + case "status": + return runStatus(args[1:], stdout, stderr) + case "agent": + return runAgent(args[1:], stdout, stderr) + default: + return runInstall(args, stdout, stderr) + } +} + +func runStatus(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("status", flag.ContinueOnError) + flags.SetOutput(stderr) + jsonOutput := flags.Bool("json", false, "write JSON") + home := flags.String("home", "", "override the home directory") + if err := flags.Parse(args); err != nil { + return oneerrors.ExitCodes[oneerrors.InvalidRequest] + } + info := platform.Current() + core := app.NewUseCases(app.StatusOptions{Home: *home, Platform: info}) + ctx, stop := flagsContext() + defer stop() + status, err := core.GetStatus(ctx) + if err != nil { + return writeError(stdout, stderr, err, *jsonOutput, "") + } + return writeValue(stdout, status, *jsonOutput) +} + +func runAgent(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + args = []string{"list"} + } + switch args[0] { + case "list": + flags := flag.NewFlagSet("agent list", flag.ContinueOnError) + flags.SetOutput(stderr) + jsonOutput := flags.Bool("json", false, "write JSON") + home := flags.String("home", "", "override the home directory") + if err := flags.Parse(args[1:]); err != nil { + return oneerrors.ExitCodes[oneerrors.InvalidRequest] + } + core := newCLIUseCases(*home) + ctx, stop := flagsContext() + defer stop() + bindings, err := core.ListAgentBindings(ctx) + if err != nil { + return writeError(stdout, stderr, err, *jsonOutput, "") + } + if *jsonOutput { + return writeJSON(stdout, map[string]any{"ok": true, "agents": bindings}) + } + if len(bindings) == 0 { + _, _ = fmt.Fprintln(stdout, "[oneagent] no Agent has been configured yet") + return 0 + } + for _, agentID := range sortedBindingIDs(bindings) { + binding := bindings[agentID] + _, _ = fmt.Fprintf(stdout, "%-14s %-10s %s\n", agentID, binding.Provider, binding.Model) + } + return 0 + case "set": + if len(args) < 2 || strings.TrimSpace(args[1]) == "" { + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "agent_id is required"), false, "") + } + flags := flag.NewFlagSet("agent set", flag.ContinueOnError) + flags.SetOutput(stderr) + providerID := flags.String("provider", "ppio", "Provider ID") + baseURL := flags.String("api-base-url", "", "custom Provider base URL") + apiKey := flags.String("api-key", "", "API key") + model := flags.String("model", "", "model ID") + profileID := flags.String("profile", "", "reuse a saved profile key") + smallFast := flags.String("small-fast-model", "", "Claude Code fast model") + jsonOutput := flags.Bool("json", false, "write JSON") + home := flags.String("home", "", "override the home directory") + if err := flags.Parse(args[2:]); err != nil { + return oneerrors.ExitCodes[oneerrors.InvalidRequest] + } + key := *apiKey + if key == "" { + key = os.Getenv("ONEAGENT_API_KEY") + } + core := newCLIUseCases(*home) + ctx, stop := flagsContext() + defer stop() + result, err := core.ActivateAgent(ctx, app.ActivateAgentOptions{ + AgentID: args[1], Provider: *providerID, APIBaseURL: *baseURL, + APIKey: key, Model: *model, ProfileID: *profileID, SmallFastModel: *smallFast, + }) + if err != nil { + return writeError(stdout, stderr, err, *jsonOutput, key) + } + if *jsonOutput { + payload := map[string]any{ + "ok": true, "agent": result.AgentID, "config": result.Config, + "provider": result.Provider, "model": result.Model, "binding": result.Binding, + "restart": result.Restart, "next": result.Next, + } + return writeJSON(stdout, payload) + } + _, _ = fmt.Fprintf(stdout, "[oneagent] %s -> %s / %s\n", result.AgentID, result.Provider, result.Model) + _, _ = fmt.Fprintln(stdout, "[oneagent] "+result.Restart) + _, _ = fmt.Fprintln(stdout, "[oneagent] next: "+result.Next) + return 0 + default: + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "Unknown agent command"), false, "") + } +} + +type installCLIFlags struct { + Agent string + Provider string + APIBaseURL string + APIKey string + Model string + SmallFastModel string + RegisterURL string + Channel string + InstallAgent bool + CheckOnly bool + SkipTest bool + NoOpen bool + JSON bool + Locked bool + Latest bool + Registry string + Home string + Timeout int +} + +func runInstall(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("oneagent", flag.ContinueOnError) + flags.SetOutput(stderr) + options := installCLIFlags{} + flags.StringVar(&options.Agent, "agent", "codex", "Agent ID; comma-separated for several") + flags.StringVar(&options.Provider, "provider", "ppio", "Provider ID") + flags.StringVar(&options.APIBaseURL, "api-base-url", "", "custom Provider base URL") + flags.StringVar(&options.APIKey, "api-key", "", "API key") + flags.StringVar(&options.Model, "model", "", "model ID") + flags.StringVar(&options.SmallFastModel, "small-fast-model", "", "Claude Code fast model") + flags.StringVar(&options.RegisterURL, "register-url", "", "registration URL") + flags.StringVar(&options.Channel, "channel", "direct", "launch channel") + flags.BoolVar(&options.InstallAgent, "install-agent", false, "install missing Agent packages") + flags.BoolVar(&options.CheckOnly, "check-agent-only", false, "only inspect Agents") + flags.BoolVar(&options.SkipTest, "skip-test", false, "skip Provider probes") + flags.BoolVar(&options.NoOpen, "no-open", false, "do not open registration URL") + flags.BoolVar(&options.JSON, "json", false, "write JSON") + flags.BoolVar(&options.Locked, "locked-version", false, "enforce locked versions") + flags.BoolVar(&options.Latest, "latest", false, "install latest version") + flags.StringVar(&options.Registry, "registry", "", "package registry mirror or HTTPS URL") + flags.StringVar(&options.Home, "home", "", "override the home directory") + flags.IntVar(&options.Timeout, "timeout", 180, "operation timeout in seconds") + if err := flags.Parse(args); err != nil { + return oneerrors.ExitCodes[oneerrors.InvalidRequest] + } + if options.Locked && options.Latest { + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "--locked-version and --latest cannot be used together"), options.JSON, "") + } + key, err := resolveCLIKey(options, stderr) + if err != nil { + return writeError(stdout, stderr, err, options.JSON, key) + } + agents := splitAgents(options.Agent) + if len(agents) == 0 { + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "At least one Agent is required"), options.JSON, key) + } + if options.Timeout <= 0 { + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "timeout must be greater than zero"), options.JSON, key) + } + core := newCLIUseCases(options.Home) + ctx, stop := flagsContext() + defer stop() + result, err := core.InstallAgents(ctx, app.InstallAgentsOptions{ + Agents: agents, Provider: options.Provider, APIBaseURL: options.APIBaseURL, + APIKey: key, Model: options.Model, SmallFastModel: options.SmallFastModel, + Configure: !options.CheckOnly, InstallAgent: options.InstallAgent, + CheckAgentOnly: options.CheckOnly, SkipTest: options.SkipTest, + LockedVersion: options.Locked, Latest: options.Latest, + Timeout: time.Duration(options.Timeout) * time.Second, Registry: options.Registry, + }) + if err != nil { + if code, ok := interruptExitCode(ctx); ok { + return code + } + return writeError(stdout, stderr, err, options.JSON, key) + } + if options.JSON { + return writeJSON(stdout, result) + } + if result.Log != "" { + _, _ = fmt.Fprintln(stdout, result.Log) + } + for line := range strings.SplitSeq(result.Next, "\n") { + if strings.TrimSpace(line) != "" { + _, _ = fmt.Fprintln(stdout, "[oneagent] next: "+line) + } + } + if result.OK { + return 0 + } + return result.Code +} + +func splitAgents(raw string) []string { + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if value := strings.TrimSpace(part); value != "" { + result = append(result, value) + } + } + return result +} + +func newCLIUseCases(home string) *app.UseCases { + info := platform.Current() + current := process.Current() + return app.NewUseCases(app.StatusOptions{ + Home: home, Platform: info, Runner: current, Environment: current.Env, + }) +} + +func resolveCLIKey(options installCLIFlags, stderr io.Writer) (string, error) { + if options.CheckOnly { + return "", nil + } + if value := os.Getenv("ONEAGENT_API_KEY"); value != "" { + return value, nil + } + if options.APIKey != "" { + return options.APIKey, nil + } + if !stdinIsTerminal() { + return "", oneerrors.New(oneerrors.InvalidRequest, "API key is required; set ONEAGENT_API_KEY or pass --api-key (pasting interactively needs a TTY)") + } + registration := options.RegisterURL + if registration == "" { + if home, ok := catalog.ProviderByID(options.Provider); ok { + registration = home.Home + } else { + registration, _ = provider.ProviderHome("ppio") + } + } + if !options.NoOpen { + _ = openRegistrationURL(registration) + } + _, _ = fmt.Fprintln(stderr, "Create or copy an API key from: "+registration) + _, _ = fmt.Fprint(stderr, "Paste API key: ") + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && len(line) == 0 { + return "", oneerrors.New(oneerrors.InvalidRequest, "API key is required") + } + return strings.TrimSpace(line), nil +} + +func stdinIsTerminal() bool { + info, err := os.Stdin.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +func openRegistrationURL(value string) error { + parsed, err := provider.ValidateBaseURL(value) + if err != nil { + // Provider home URLs end in a path and satisfy the same URL safety rules; + // preserve the stable request error if a caller supplied an unsafe value. + return err + } + var command string + var args []string + switch runtime.GOOS { + case "darwin": + command, args = "open", []string{parsed} + case "windows": + command, args = "rundll32", []string{"url.dll,FileProtocolHandler", parsed} + default: + command, args = "xdg-open", []string{parsed} + } + return exec.Command(command, args...).Start() +} + +func writeValue(stdout io.Writer, value any, jsonOutput bool) int { + if jsonOutput { + return writeJSON(stdout, value) + } + _, _ = fmt.Fprintln(stdout, "OneAgent status is available with --json") + return 0 +} + +func writeJSON(stdout io.Writer, value any) int { + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return oneerrors.ExitCodes[oneerrors.InternalError] + } + return 0 +} + +func writeError(stdout, stderr io.Writer, err error, jsonOutput bool, secret string) int { + oneErr := oneerrors.As(err) + if jsonOutput { + if code := writeJSON(stdout, oneErr.APIShape()); code != 0 { + return code + } + return oneErr.ExitCode + } + message := oneErr.Message + if secret != "" { + message = strings.ReplaceAll(message, secret, "[redacted]") + } + _, _ = fmt.Fprintln(stderr, "[oneagent] error: "+message) + return oneErr.ExitCode +} + +// helpRequested reports whether the argument list asks for usage rather than an +// operation. Go's flag package treats -h as a parse error and exits non-zero; +// the wrapper contract expects help to exit 0, so handle it before any FlagSet +// sees the arguments. +func helpRequested(args []string) bool { + for _, arg := range args { + switch arg { + case "-h", "--help", "help": + return true + case "--": + return false + } + } + return false +} + +// printUsage keeps the wrapper's public flag names and double-dash form stable. +func printUsage(stdout io.Writer) { + // One write keeps help safe for callers piping it into + // `grep -q`, which closes the pipe on its first match; line-by-line writes + // would take a SIGPIPE mid-help and fail the caller's pipeline. + lines := []string{ + "usage: oneagent [--agent AGENT] [--provider PROVIDER]", + " [--api-base-url API_BASE_URL] [--api-key API_KEY]", + " [--model MODEL] [--small-fast-model MODEL]", + " [--register-url URL] [--channel CHANNEL] [--install-agent]", + " [--check-agent-only] [--skip-test] [--no-open] [--json]", + " [--locked-version] [--latest] [--registry REGISTRY]", + " [--timeout SECONDS]", + " oneagent status [--json]", + " oneagent agent list [--json]", + " oneagent agent set AGENT_ID [--provider PROVIDER] [--model MODEL]", + " [--api-base-url URL] [--api-key API_KEY]", + " [--profile PROFILE] [--json]", + " oneagent --version", + "", + "Install or configure one Agent with OneAgent", + "", + "options:", + " -h, --help show this help message and exit", + " --agent AGENT Agent ID; comma-separated for several", + " --provider PROVIDER Provider ID; defaults to ppio", + " --api-base-url API_BASE_URL", + " Custom Provider base URL", + " --api-key API_KEY API key; prefer ONEAGENT_API_KEY", + " --model MODEL Defaults to the provider's probe model", + " --small-fast-model MODEL", + " Claude Code only: a cheaper fast/background model", + " --register-url URL Registration URL to open when a key is missing", + " --channel CHANNEL Launch channel recorded with the install", + " --install-agent Install missing Agent packages", + " --check-agent-only Only inspect Agents; writes no configuration", + " --skip-test Skip Provider probes", + " --no-open Do not open the registration URL", + " --json Write a JSON result", + " --locked-version Enforce the version in agents.lock.json", + " --latest Install the latest version instead of the locked one", + " --registry REGISTRY Package registry: a mirror id (official, npmmirror) or", + " an https:// URL. Defaults to the official registry.", + " --timeout SECONDS Operation timeout in seconds; defaults to 180", + } + _, _ = fmt.Fprintln(stdout, strings.Join(lines, "\n")) +} + +// flagsContext cancels the running operation on the first interrupt so provider +// requests and package-manager subprocesses stop with it. A second interrupt +// falls through to the runtime's default handler. +func flagsContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) +} + +// interruptExitCode reports the shell convention for an interrupted command, +// matching the shell convention for an interrupted command. An interrupt is +// not an operation failure, so no error payload is written for it. +func interruptExitCode(ctx context.Context) (int, bool) { + if ctx.Err() == nil { + return 0, false + } + return 130, true +} + +func sortedBindingIDs(bindings map[string]profileStore.AgentBinding) []string { + ids := make([]string, 0, len(bindings)) + for id := range bindings { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} diff --git a/cmd/oneagent/main_test.go b/cmd/oneagent/main_test.go new file mode 100644 index 00000000..7016bce7 --- /dev/null +++ b/cmd/oneagent/main_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MaimoryLab/OneAgent/internal/app" + "github.com/MaimoryLab/OneAgent/internal/platform" +) + +func TestFlatInstallCLIEmitsStructuredGuideResult(t *testing.T) { + home := t.TempDir() + var stdout, stderr bytes.Buffer + code := run([]string{"--agent", "openclaw", "--check-agent-only", "--json", "--home", home}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["ok"] != true { + t.Fatalf("payload=%v", payload) + } + results, ok := payload["results"].([]any) + if !ok || len(results) != 1 || results[0].(map[string]any)["status"] != "guide-only" { + t.Fatalf("results=%v", payload["results"]) + } +} + +func TestAgentSetAndListCLIUseGoBindingsWithoutLeakingKey(t *testing.T) { + home := t.TempDir() + var stdout, stderr bytes.Buffer + code := run([]string{"agent", "set", "codex", "--provider", "ppio", "--model", "model-a", "--api-key", "cli-secret", "--json", "--home", home}, &stdout, &stderr) + if code != 0 { + t.Fatalf("set exit=%d stderr=%s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "cli-secret") { + t.Fatal("API key appeared in agent set output") + } + var setPayload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &setPayload); err != nil || setPayload["provider"] != "ppio" { + t.Fatalf("set payload=%s err=%v", stdout.String(), err) + } + if _, err := os.Stat(filepath.Join(home, ".oneagent", "agents", "codex.json")); err != nil { + t.Fatal(err) + } + + stdout.Reset() + stderr.Reset() + code = run([]string{"agent", "list", "--json", "--home", home}, &stdout, &stderr) + if code != 0 { + t.Fatalf("list exit=%d stderr=%s", code, stderr.String()) + } + var listPayload struct { + OK bool `json:"ok"` + Agents map[string]map[string]any `json:"agents"` + } + if err := json.Unmarshal(stdout.Bytes(), &listPayload); err != nil || !listPayload.OK { + t.Fatalf("list payload=%s err=%v", stdout.String(), err) + } + if listPayload.Agents["codex"]["model"] != "model-a" { + t.Fatalf("agents=%v", listPayload.Agents) + } +} + +// The compatibility wrappers and tests/install_test.sh grep this help text and +// treat a non-zero exit as a failure, so help keeps the wrapper's contract: +// exit 0, double-dash flag names, one write. +func TestHelpMatchesTheCLIContract(t *testing.T) { + for _, args := range [][]string{{"--help"}, {"-h"}, {"help"}, {"--agent", "codex", "--help"}} { + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr); code != 0 { + t.Fatalf("%v exit=%d stderr=%s", args, code, stderr.String()) + } + help := stdout.String() + for _, flagName := range []string{ + "--register-url URL", "--agent AGENT", "--check-agent-only", + "--locked-version", "--latest", "--registry REGISTRY", "--skip-test", + } { + if !strings.Contains(help, flagName) { + t.Fatalf("%v help is missing %q", args, flagName) + } + } + if stderr.Len() != 0 { + t.Fatalf("%v wrote help diagnostics to stderr: %s", args, stderr.String()) + } + } +} + +// An interrupt is not an operation failure: it exits with the shell convention +// and writes no error payload, matching the CLI interrupt path. +func TestInterruptExitCodeUsesShellConventionWithoutErrorPayload(t *testing.T) { + if code, interrupted := interruptExitCode(context.Background()); interrupted || code != 0 { + t.Fatalf("uncancelled context reported code=%d interrupted=%v", code, interrupted) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + code, interrupted := interruptExitCode(cancelled) + if !interrupted || code != 130 { + t.Fatalf("cancelled context reported code=%d interrupted=%v", code, interrupted) + } +} + +// The install path must observe cancellation rather than run to completion, so +// a signal stops provider requests and package-manager subprocesses with it. +func TestInstallHonoursACancelledContext(t *testing.T) { + home := t.TempDir() + core := app.NewUseCases(app.StatusOptions{Home: home, Platform: platform.Current()}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := core.InstallAgents(ctx, app.InstallAgentsOptions{ + Agents: []string{"codex"}, Provider: "ppio", APIKey: "cancel-secret", + Model: "model-a", Configure: true, SkipTest: true, Timeout: 30 * time.Second, + }) + if err == nil { + t.Fatal("a cancelled install returned no error") + } + if strings.Contains(err.Error(), "cancel-secret") { + t.Fatalf("cancellation error leaked the API key: %v", err) + } + if _, statErr := os.Stat(filepath.Join(home, ".codex", "config.toml")); statErr == nil { + t.Fatal("a cancelled install still wrote Agent configuration") + } +} + +func TestFlatCLIRejectsEmptyAgentListAndConflictingVersionModes(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := run([]string{"--agent", ",", "--check-agent-only"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "At least one Agent") { + t.Fatalf("empty agents exit=%d stderr=%q", code, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if code := run([]string{"--check-agent-only", "--latest", "--locked-version", "--json"}, &stdout, &stderr); code != 2 { + t.Fatalf("conflicting modes exit=%d output=%q", code, stdout.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil || payload["error_code"] != "INVALID_REQUEST" { + t.Fatalf("error payload=%q err=%v", stdout.String(), err) + } +} diff --git a/distribution/channels.json b/distribution/channels.json deleted file mode 100644 index b52aae7b..00000000 --- a/distribution/channels.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "schema_version": 1, - "product": { - "name": "OneAgent", - "tagline": "用一个可信的本地流程,激活你自己的 Agent、账号和 Provider。", - "release_policy": "security/", - "privacy_policy": "security/#privacy" - }, - "channels": { - "technical-preview-unsigned": { - "label": "未签名技术预览版", - "published_at": "2026-07-28T00:00:00Z", - "targets": [ - { - "platform": "macos", - "arch": "arm64", - "status": "available", - "verification": { - "native_build": true, - "cleanroom": "verified", - "evidence": "security/#release-evidence" - }, - "mirrors": [ - { - "id": "website", - "label": "官网下载", - "kind": "official", - "url": "downloads/{file}", - "primary": true - } - ] - }, - { - "platform": "macos", - "arch": "x64", - "status": "verification-pending", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "windows", - "arch": "x64", - "status": "verification-pending", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "linux", - "arch": "x64", - "status": "verification-pending", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - } - ] - }, - "stable": { - "label": "Stable", - "published_at": null, - "targets": [ - { - "platform": "macos", - "arch": "arm64", - "status": "planned", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "macos", - "arch": "x64", - "status": "planned", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "windows", - "arch": "x64", - "status": "planned", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "linux", - "arch": "x64", - "status": "planned", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - } - ] - } - } -} diff --git a/docs/ai-agent-kit/00-start-here.md b/docs/ai-agent-kit/00-start-here.md index fd1dd1f1..c2cbd7c2 100644 --- a/docs/ai-agent-kit/00-start-here.md +++ b/docs/ai-agent-kit/00-start-here.md @@ -8,7 +8,7 @@ ## 开始前准备 -- 一台可以运行 Python 3 的设备。 +- 一台支持的 macOS、Windows 或 Linux 设备;普通 OneAgent 流程不需要 Python。 - 一个可用的模型 Provider 账号,或准备注册一个。 - 你准备使用的 Agent,例如 Codex、Claude Code、OpenCode 或 Aider。 @@ -27,13 +27,15 @@ 如果你已经熟悉多 Provider 切换,可以在配置方式页面选择 **CC Switch**,然后阅读 [配置工具选择](./03-config-tools.md) 和 [CC Switch 指引](./tools/cc-switch.md)。 -## 启动本地 GUI +## 启动桌面应用 ```bash -python3 scripts/gui.py +cd frontend && npm ci && npm run build +cd .. +go run -tags wails ./cmd/oneagent-desktop ``` -GUI 只监听本机 `127.0.0.1`,不会主动暴露到局域网。 +也可以下载对应平台的 `technical-preview-unsigned` 包后直接启动 Wails 应用。 ## 三条安全规则 @@ -58,4 +60,3 @@ OneAgent 不提供 VPN、代理、节点订阅或绕过网络限制的配置。 - 配置工具问题:查看 [配置工具选择](./03-config-tools.md)。 - Agent 问题:查看 [Agent 分类和安装指引](./04-agent-guides.md)。 - 请求问题:查看 [第一次请求验证](./05-first-request.md)。 - diff --git a/docs/ai-agent-kit/04-agent-guides.md b/docs/ai-agent-kit/04-agent-guides.md index a6ab48a6..1ebc8476 100644 --- a/docs/ai-agent-kit/04-agent-guides.md +++ b/docs/ai-agent-kit/04-agent-guides.md @@ -24,8 +24,8 @@ ### Aider -- 自动安装使用 `uv tool` 和本机已有的 Python 3.12,不使用系统级 pip。 -- 缺少 `uv` 或 Python 3.12 时先完成官方前置条件安装;OneAgent 不自动下载 Python。 +- 只有选择 Aider 安装时才使用 `uv tool` 和本机已有的 Python 3.12;其他 Agent 和 OneAgent 自身不需要 Python。 +- 缺少 `uv` 或 Python 3.12 时先完成 Aider 官方前置条件安装;OneAgent 不自动下载运行时。 - 使用独立环境文件保存 PPIO 配置。 - 启动前加载环境文件。 - 使用 `openai/` 形式时,以 Aider 当前版本说明为准。 diff --git a/docs/ai-agent-kit/manifest.md b/docs/ai-agent-kit/manifest.md index 8b2e7896..60146711 100644 --- a/docs/ai-agent-kit/manifest.md +++ b/docs/ai-agent-kit/manifest.md @@ -7,8 +7,6 @@ | `launcher` | 启动本地 GUI | 否 | | `start.sh` | macOS / Linux 启动入口 | 否 | | `start.command` | macOS 双击入口 | 否 | -| `scripts/install.sh` | 检测、安装和写配置 | 运行时输入 | -| `scripts/verify.sh` | 连通性和模型验证 | 运行时输入 | ## 文档文件 diff --git a/docs/blank-machine-verification-plan.md b/docs/blank-machine-verification-plan.md index 21b01a04..bf88f467 100644 --- a/docs/blank-machine-verification-plan.md +++ b/docs/blank-machine-verification-plan.md @@ -1,189 +1,33 @@ -# 空白机器可用性验证计划(Codex 与 Claude Code) +# 空白机器可用性验证计划 -目标:证明一台没装过任何 Agent 的机器,能用 OneAgent 把 Codex 和 Claude Code 装好并真正跑起来。范围限定这两个自动配置 Agent。 +> 状态:已实施(2026-07-31)。实现入口已从历史脚本切换为 Go CLI、Go RC 命令和 shell cleanroom。 -> **实施状态(2026-07-29)**:四层全部落地。`tests/test_install_contract.py`(29 用例,离线)进常规 CI;`tests/real_install_test.sh` 真实安装两个 Agent,官方源与 npmmirror 双路径实测通过,进 RC workflow;`scripts/agent_e2e_smoke.py` 需真实 Key,手动运行;integrity 校验已在 `install_locked_agent()` 生效。`installer.py` 保持 274/274 分支、零 partial。 +## 验证层级 -两个结论: +| 层级 | 入口 | 证明内容 | +| --- | --- | --- | +| RC 安装 | `go run ./cmd/oneagent-rc verify-agents` | 多 Agent 隔离安装、版本和可执行文件 | +| 配置采用 | `go run ./cmd/oneagent-rc adopted` | Codex/Claude Code 是否真正读取丢弃端点配置 | +| Provider | `go run ./cmd/oneagent-provider-smoke` | models、Chat、Responses、Anthropic Messages | -**现有验证设施覆盖不到这条链路。** 不是测试写得不够多,而是所有既有测试都绕开了「真的装包」和「真的连服务商」这两步。已手动补跑(第 4 节),装得上,但结论未固化成可重复的脚本。 +普通 CI 不访问 registry 或真实 Provider;Release Candidate workflow 在受保护环境中执行真实网络检查。Aider 需要其上游声明的 Python 3.12,但不纳入普通 Go/Wails cleanroom。 -**国内网络下需要授权镜像,这已落在产品边界内。** 镜像与官方源的 integrity 逐字节相同,属「同包不同渠道」而非绕过网络限制,详见 3.5 节。 +## 关键断言 -## 1. 现有设施实际验了什么 +- npm prefix 必须排在 PATH 首位,不能由开发机全局 Agent 假通过。 +- 安装后每个 Agent 的版本必须等于 `agents.lock.json`。 +- 配置文件和 secret 文件使用预期的 `0700/0600` 权限。 +- 真实用户 HOME 在测试前后快照必须完全一致。 +- API Key 不出现在 profile、CLI JSON、日志或测试附件。 +- 配置指向 `127.0.0.1:9` 时,采用配置的 Agent 应报连接失败而不是登录/认证错误。 +- release check 必须拒绝 source map、远程资源、secret、Agent 二进制和旧 runtime。 -| 设施 | 真装包 | 真连 Provider | 实际覆盖 | -| --- | --- | --- | --- | -| `tests/test_core.py` 等 162 个 Python 用例 | 否,`Runtime.runner` 被替换 | 否 | 配置文件内容、错误码、权限 | -| `tests/install_test.sh` (263 行) | 否,全程 `--skip-test` | 否 | CLI 参数契约、退出码 | -| `tests/macos_cleanroom_test.sh` (301 行) | **否** | 否,指向 `127.0.0.1:9` | 干净 HOME 下写配置、文件权限 0600/0700、密钥不落盘 | -| `scripts/run_container_cleanroom.sh` | 否 | 否 | Linux 断网环境下的策略扫描 | -| `scripts/verify_locked_agents.py`(RC workflow 调用) | **是** | 否 | 五个 Agent 全部真实安装、隔离前缀、版本断言 | -| `scripts/provider_rc_smoke.py` | 不适用 | **是** | Provider 端点可达性 | - -**更正**:先前记录的「没有任何一层真的装包」不成立。`scripts/verify_locked_agents.py` 一直在真实安装:`install_agent=True`、`locked_version=True`、隔离的 npm 前缀与 uv 目录、剔除带 `KEY`/`TOKEN`/`SECRET` 的环境变量,装完还用 `installed_version()` 断言版本相符(`:113`),且覆盖全部五个自动配置 Agent。它由 RC workflow 的 `Install and verify all locked Agents` 步骤调用。**真实安装本来就有覆盖,缺的是它之后的两步。** - -实际的三个缺口: - -**macOS cleanroom 链接了真实 npm 却从不用它。** `tests/macos_cleanroom_test.sh:40` 把真实 `npm` 链进干净 PATH,看起来具备安装能力,但 `:235-244` 的循环只传 `--provider/--model/--skip-test`,走纯配置路径。它验证「配置写得对」,不验证「Agent 装得上」——后者由 `verify_locked_agents.py` 在另一处覆盖。 - -**可执行文件是否落到 PATH 无人断言。** `verify_locked_agents.py` 用注入的 `isolated_which` 在自己构造的 PATH 里查找,验证的是「OneAgent 认为它装好了」。真实机器上 npm 全局前缀的差异是最常见的故障点,而这一步没有独立验证。 - -**`integrity` 记录了但从不校验。** `agents.lock.json` 为两个 Agent 都记了 `sha512-`,`tests/test_release_policy.py:36` 也断言它存在,但 `install_locked_agent()` 全函数不出现 `integrity`,只执行 `npm install -g @`。**版本锁住了,字节没有。** - -## 2. 待验证的链路 - -空白机器到「Agent 能回答一个请求」,中间有六个环节,每一个都可能独立失败: - -1. **前置条件** —— Node.js 是否存在、版本是否够;Windows 上 Claude Code 还要求 `git`(lock 里的 `windows_prerequisites`)。 -2. **装包** —— `npm install -g @openai/codex@0.145.0` 与 `@anthropic-ai/claude-code@2.1.217` 在真实 registry 上是否可解析、可安装。 -3. **可执行文件落到 PATH** —— 装完之后 `runtime.which("codex")` / `which("claude")` 是否真的找得到。npm 全局前缀在不同机器上差异很大,这一步是最常见的实际故障点。 -4. **版本一致** —— `codex --version` 报出的是否就是锁定的版本。 -5. **写配置** —— 这一环现有 cleanroom 已覆盖,且是唯一被覆盖的。 -6. **Agent 真的能用** —— 带着写好的配置和真实 Key,Codex 和 Claude Code 能否各自完成一次请求。这是「可用」的唯一判据,也是目前完全空白的一环。 - -环节 3 与 6 是这次要补的重点:前者决定装完能不能被调用,后者决定配置写对了是否等于能用。 - -## 3. 检查计划 - -分三层,按成本和可重复性从低到高。第一层进常规 CI,第二三层手动或按发行触发——**真实装包与真实 Key 不能进常规 CI**,那会让每次提交都打 registry 和 Provider。 - -### 第一层:装包契约(进常规 CI,无网络) - -补 `tests/test_install_contract.py`,用替换过的 `Runtime.runner` 断言即将执行的命令,不真的执行: - -- Codex 与 Claude Code 的安装命令恰好是 `npm install -g @`,版本取自 lock 而非硬编码。 -- `--latest` 未指定时命令里必须带 `@`;这是版本锁定的直接体现。 -- 已装且版本相符时不重复安装(`install_locked_agent` 的短路分支)。 -- 已装但版本落后时会重装。 -- Node.js 缺失时报 `PREREQUISITE_MISSING` 而不是让 npm 自己失败。 -- Windows 平台缺 `git` 时 Claude Code 报 `PREREQUISITE_MISSING`(用 `Runtime(os_id="windows")` 模拟)。 - -这一层保证命令构造正确,能在几毫秒内跑完,且不依赖外网。 - -### 第二层:真实安装 cleanroom(手动 / 发行前) - -新增 `tests/real_install_test.sh`,与 macOS cleanroom 同样的隔离方式(干净 `HOME`、`env -i`、前后快照真实 HOME 确认零污染),但**真的装包**: - -1. 断言起点为空:`codex` 与 `claude` 都不在 PATH 上,干净 HOME 下无 `.codex` / `.claude`。 -2. 用真实 npm 安装两个 Agent 的锁定版本,npm 全局前缀指向干净 HOME 内的临时目录(避免污染真实机器)。 -3. **断言可执行文件确实出现在 PATH 上**——环节 3,现有测试完全没有覆盖。 -4. 断言 `codex --version` 与 `claude --version` 报出锁定版本,不是别的版本。 -5. 断言 `/api/status` 把两者报成 `installed: true` 且版本相符(走真实 HTTP,与 `gui_smoke_test.py` 同样方式)。 -6. 断言真实 HOME 前后快照一致——这是 macOS cleanroom 已有的做法,直接沿用。 - -只装这两个 Agent,不碰另外三个。默认不在 CI 跑;给 `release-candidate.yml` 加一个显式 job,并把那个名不副实的 `native-build-and-agent-install` 改成实际执行安装,或改名为它真正做的事。 - -### 第三层:端到端可用性(手动,需真实 Key) - -新增 `scripts/agent_e2e_smoke.py`,在第二层基础上加真实 Provider: - -1. 用真实 Key 走完整 `install_many`(不带 `--skip-test`),让协议探测真实执行——Codex 走 Responses,Claude Code 走 Anthropic Messages,两者协议不同,这正是需要分别验证的原因。 -2. 读回写好的配置,确认 Codex 的 `config.toml` 里 `env_key` 指向的环境变量文件存在且含 Key,Claude Code 的 `settings.json` 里四个 `ANTHROPIC_*` 变量齐全。 -3. **实际调用两个 Agent 各完成一次最小请求**,断言退出码为 0 且有输出。这是唯一能证明「可用」的一步。 -4. 断言全过程的日志里不出现 Key 明文(`redact` 生效),沿用 macOS cleanroom 的 `grep -R -Fq` 手法。 - -只手动运行,需要 `ONEAGENT_API_KEY` 与一个可用 Provider。产出写进 `docs/release-evidence/`,与现有 cleanroom 证据同格式。 - -## 3.5 安装源可达性:授权镜像(已核实可行) - -上面第 2 节的环节 2 假设 `registry.npmjs.org` 可达。在国内网络下这个假设经常不成立,而这恰好是 [产品边界基线](product-boundary-baseline.md) 已经预留了答案的场景。 - -### 为什么这不是「绕过网络限制」 - -基线第 5 节的软件获取策略把「授权镜像」列为优先级 2,条件是**有许可证、版本锁定、校验值和上游地址**;第 3.2 节允许「许可证允许的开源软件镜像」与「同包镜像」,同时第 4 节禁止「翻墙下载」「免代理访问受限网站」和「把 OneAgent 的服务器作为中转代理」。 - -分界线在于:**换 registry 是换取包的渠道,不是代理用户的网络。** OneAgent 不建隧道、不转发流量、不接触用户与镜像之间的连接,只是把 npm 的下载地址指向另一个同样公开可达的地址。这与基线允许「网盘和企业云盘上的同包镜像」是同一性质。 - -已核实的关键事实(2026-07-29): +## 运行 +```bash +go build -o bin/oneagent ./cmd/oneagent +go run ./cmd/oneagent-rc verify-agents +go run ./cmd/oneagent-rc adopted ``` -@openai/codex@0.145.0 - 官方 dist.integrity sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ== - 镜像 dist.integrity sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ== - -@anthropic-ai/claude-code@2.1.217 - 官方 dist.integrity sha512-EIcc3GmI7x+qPlKCjpcLIjCh7YOaCFbOqKfL4BmwZS6QmtduVNT5E98oyr8n2cxsgeWVbnQ0mSVljTw5C/kFtA== - 镜像 dist.integrity sha512-EIcc3GmI7x+qPlKCjpcLIjCh7YOaCFbOqKfL4BmwZS6QmtduVNT5E98oyr8n2cxsgeWVbnQ0mSVljTw5C/kFtA== -``` - -两者**逐字节相同,且正好等于 `agents.lock.json` 里已记录的 `integrity`**。所以这不是「换了个源装到不同的东西」,而是同一份包的另一个渠道——基线第 3.2 节要求的「同一版本的所有渠道必须使用相同产物和相同 SHA-256」天然满足。 - -实测从镜像安装两个 Agent 共 4 秒完成,版本报告 `codex-cli 0.145.0` 与 `2.1.217 (Claude Code)`,与锁定一致。 - -### Claude Code 的许可证需要单独说明 - -`agents.lock.json` 记录 Claude Code 的 license 是 **Proprietary**,而基线第 4 节禁止「未经许可重新分发商业 Agent 包体」。这一条**不构成障碍,但理由必须写准**: - -我们不重新分发任何包体。npmmirror 是上游 registry 的公开只读镜像,包由版权方自己发布到 npm;OneAgent 只是让用户的 npm 从哪个地址取包。禁止的是我们自己托管、重打包或再分发——那需要授权,而指向一个公开镜像不需要。 - -**因此实现上有一条硬性约束:绝不把 Agent 包体放进 OneAgent 自己的任何渠道**(发行包、对象存储、网盘)。镜像只能是第三方公开 registry,不能是我们运营的存储。 - -### 实现方式 - -落点很干净,因为 `Runtime.env` 已经是可注入字段,且 `install_locked_agent()` 已经把 `env=runtime.env` 传给 runner。所以不需要改命令构造,只需要在 env 里设 `npm_config_registry`: - -- `catalog.py` 增一个 `PACKAGE_MIRRORS` 常量,声明可选镜像及其上游地址与用途说明。每个条目必须带上游 registry 地址,满足基线「有上游地址」的要求。 -- 新增 `--registry ` CLI 参数与对应的请求字段;**默认保持官方源**,镜像永远是用户显式选择的结果,不做自动探测切换。这一点重要:自动切换会让用户不知道包从哪来。 -- 只接受 HTTPS,且校验 URL 形态(复用 `providers.py` 里 base URL 校验的同类做法),拒绝 `http://` 和畸形值。 -- 安装日志里记录实际使用的 registry。用户必须能事后知道包是从哪个地址来的。 -- 前端在高级项里提供镜像选择,收起时说明「默认使用官方源,网络不可达时可选国内镜像」。 - -### 与 integrity 校验的关系 - -**镜像使这一层从可选变成必要。** 官方源下 integrity 只是「记而不验」的落差;一旦允许第三方镜像,「同包」就从 npm 的信任模型变成了我们自己的声明。`npm install` 会用 registry 自己返回的 integrity 校验下载,但那是镜像说什么就信什么。 - -所以引入镜像的同时,`install_locked_agent()` 应当把 `agents.lock.json` 里记录的 integrity 与实际安装的包核对——这是把「授权镜像」从口头承诺变成可执行检查的唯一方式,也正是基线第 5 节要求镜像必须有「校验值」的本意。 - -### 测试 - -- `PACKAGE_MIRRORS` 每个条目都有 HTTPS 地址、上游地址和说明(同 `test_release_policy.py` 对 lock 的断言风格)。 -- `--registry` 未指定时,env 里不出现 `npm_config_registry`——默认行为不变。 -- 指定时 env 正确携带,且命令构造不因此改变(版本锁定不受影响)。 -- `http://` 与畸形 URL 被拒。 -- 安装日志包含实际 registry,且不含任何密钥。 -- 第二层的真实安装脚本增加一轮镜像安装,断言装出的版本与官方源一致。 - -### 不做 - -- **不自动探测网络并切换源。** 用户显式选择,否则包的来源变成隐式行为。 -- **不自建镜像,不把 Agent 包体放进 OneAgent 的任何渠道。** 见上面的许可证说明。 -- **不为 registry 做代理、隧道或任何形式的流量转发。** 基线第 4 节明确禁止,且这与换 registry 是完全不同的两件事。 -- **不因为镜像可用就放宽版本锁定。** 镜像上取不到锁定版本时应当报「安装源不可达」,而不是退到别的版本——基线第 5 节最后一段已经规定了这个行为。 - -### 第四层:integrity 校验 - -`integrity` 目前记而不验:`agents.lock.json` 为两个 Agent 都记了 `sha512-`,`tests/test_release_policy.py:36` 断言它存在,但 `install_locked_agent()` 全函数不出现 `integrity`。**版本锁住了,字节没锁。** - -只用官方源时这是一个可以接受的落差——npm 自己会用 registry 返回的 integrity 校验下载,信任链落在 npm 身上。**但一旦引入第三方镜像(3.5 节),这一层就从可选变成必要**:镜像返回的 integrity 是镜像自己说的,我们凭什么相信它与官方一致?答案只能是拿 lock 里记录的值去核对。 - -所以优先级取决于是否实施 3.5: -- 只做前三层、不引入镜像 —— 第四层可以留作已知落差,记录在案即可。 -- 实施镜像 —— 第四层必须同时落地,否则「授权镜像」缺了基线第 5 节要求的「校验值」这一条件。 - -## 4. 现状确认(已执行,2026-07-29) - -在写任何新测试之前先手动跑了一遍,用隔离的 `npm_config_prefix` 与干净 `HOME`,不污染本机。环节 1–5 全部通过: - -| 环节 | 结果 | -| --- | --- | -| 1 前置条件 | Node v22.23.1 / npm 10.9.8 | -| 2 装包 | 两个锁定版本在 registry 上均存在,`npm install -g` 各 2–3 秒完成 | -| 3 落到 PATH | `codex` 与 `claude` 都出现在前缀的 `bin/` 下,可调用 | -| 4 版本一致 | `codex-cli 0.145.0`、`2.1.217 (Claude Code)`,与锁定完全相符 | -| 5 写配置 | 两个 Agent 的配置均落地,权限 0600;`config.toml` 的 `env_key` 正确指向 `ONEAGENT_API_KEY_CODEX`;`profile.json` 无明文密钥 | - -同时确认 `status_payload()` 在隔离环境中把两者都报成 `installed: true` 且版本匹配——**OneAgent 对真实安装的检测是准的**,此前只有替换过 `runner` 的单元测试覆盖这一点。 - -**所以「装得上」不再是假设,锁定版本无需调整。** 剩下唯一未验证的是环节 6(带真实 Key 让两个 Agent 各完成一次请求),需要可用的 Provider 凭据,属第三层。 - -这次是手动执行、结论未固化。第二层的价值就是把上面这张表变成可重复运行的脚本,避免下次锁定版本变更后又要靠手工确认。 - -## 5. 明确不做 - -- **不在常规 CI 里真实装包或连 Provider。** 每次提交打 registry 与服务商既慢又会触发限流,也让 CI 结果依赖外部可用性。这也是现有 CI 用假 npm 的正当理由——问题不在于它用了假 npm,而在于**没有任何一层用真的**。 -- **不验证另外三个 Agent。** 本轮范围就是 Codex 与 Claude Code。 -- **不为 guide-only Agent 做安装验证。** 它们按设计不由 OneAgent 安装。 -- **不改 `agents.lock.json` 的锁定版本**,除非第 4 节的现状确认表明当前版本已装不上。 -相关文档:[产品边界基线](product-boundary-baseline.md)、[三平台 Python 内核与版本锁定 ADR](decisions/ADR-003-three-platform-python-core-and-release-policy.md)。 +真实安装会访问 npm registry,应在隔离网络和可审计的 runner 中运行。 diff --git a/docs/cc-switch-reference-notes.md b/docs/cc-switch-reference-notes.md index 03fc544d..a5c797cf 100644 --- a/docs/cc-switch-reference-notes.md +++ b/docs/cc-switch-reference-notes.md @@ -8,7 +8,7 @@ | | CC Switch | OneAgent | | --- | --- | --- | -| 形态 | Tauri 桌面应用(Rust + React) | 本地 HTTP + React,Python 标准库内核 | +| 形态 | Tauri 桌面应用(Rust + React) | Wails 桌面应用(Go + React) | | 职责 | Provider 配置的**存取与切换** | Agent 的**检测、安装、配置** | | 配置模型 | 整块存原始配置对象 | 结构化字段,由适配器翻译 | | 外围能力 | 本地代理、熔断器、故障转移、用量统计、MCP、skills、prompts、sessions | 无 | diff --git a/docs/config-chain-audit.md b/docs/config-chain-audit.md index bcdc7f97..9f388df0 100644 --- a/docs/config-chain-audit.md +++ b/docs/config-chain-audit.md @@ -1,133 +1,41 @@ -# 配置链路实测与硬编码审查 +# 配置链审计(已实施) -只验证 Codex 与 Claude Code。问的是两件事:配置写完之后 Agent 是否真的采用;以及支撑这条链路的代码是否可扩展。 +> 更新:2026-07-31。历史审计发现已落实到 Go 核心;本文不再引用已删除的旧实现路径。 -两条结论: +## 当前链路 -- **Codex 通,Claude Code 不通。** 我们对 Claude Code 只写 `settings.json`,而它不从那里取认证,实机启动得到 `Not logged in`——但 OneAgent 报的是 `status: configured`。 -- **硬编码规模足以阻碍扩展。** `agents.lock.json` 号称唯一真源,但命令名与配置路径在 Python 里被重复写了一遍,新增一个 Agent 要改 6 处分散代码。 - -## 1. 实测:配置写完之后 Agent 认不认 - -方法是把配置指向 `127.0.0.1:9`(丢弃端口)。若 Agent 报连接失败,说明它读到并采用了我们的配置;若报别的,说明配置没生效。 - -### Codex:通 - -``` -$ codex exec --skip-git-repo-check "say ok" -provider: oneagent -ERROR: Reconnecting... 1/5 -``` - -`provider: oneagent` 是决定性的——Codex 读了我们写入 `~/.codex/config.toml` 的 `[model_providers.oneagent]`,并按 `base_url` 去连。`env_key = "ONEAGENT_API_KEY_CODEX"` 也生效,密钥经环境变量间接传入。**这条链路完整。** - -### Claude Code:不通 - -``` -$ claude -p "say ok" # HOME 指向写好 settings.json 的干净目录 -Not logged in · Please run /login -``` - -同一份配置改用环境变量直接给,则不再报错(进入连接尝试): - -``` -$ ANTHROPIC_BASE_URL=... ANTHROPIC_AUTH_TOKEN=... claude -p "say ok" -(无报错输出) -``` - -**所以 `settings.json` 的 `env` 块不足以让 Claude Code 认证。** 我们写进去的四个变量: - -```json -{"env": {"ANTHROPIC_BASE_URL": "...", "ANTHROPIC_AUTH_TOKEN": "...", - "ANTHROPIC_MODEL": "...", "ANTHROPIC_SMALL_FAST_MODEL": "..."}} -``` - -而 OneAgent 对此报告: - -``` -status: configured -next: claude -``` - -**这是本轮最严重的问题**:产品声称配置完成并给出启动命令,用户照做会撞上 `Not logged in`,且没有任何线索指向 OneAgent。相比之下 Codex 之所以能用,正是因为它额外有 `~/.oneagent/agents/codex.env`。 - -根因在 `install_many`(`installer.py:1137`)与 `activate_agent`(`:1375`)都写着: - -```python -if agent_id in {"codex", "opencode", "kilo-cli"}: - write_agent_env(...) +```text +agents.lock.json + | +internal/catalog + | +internal/app validation + | +internal/provider protocol/base resolution + | +internal/config adapter + securefs atomic write + | +internal/profile binding/secret store + | +Wails service or cmd/oneagent ``` -**Claude Code 是唯一被排除在 env 文件之外、却又依赖环境变量的自动配置 Agent。** Aider 有自己的 `aider.env`,另外三个有 `agents/.env`,只有它两头都没有。 +## 已修复的风险 -## 2. 硬编码审查 +- Agent 命令、配置路径、版本、平台和 package manager 从 lock manifest 读取。 +- 配置适配器只在 Go 中按 adapter 分派,格式差异不伪装成数据配置。 +- 每个 Agent 的凭据交付方式由 `credential_delivery` 和 `env_vars` 声明。 +- Claude Code 的 native env 与配置文件同步写入,避免出现配置显示完成但运行时未登录。 +- Codex、Claude Code、OpenCode、Kilo CLI、Aider 按各自协议探测。 +- 备份、临时文件、权限和原子替换由 `securefs` 统一处理。 +- RC 的 `adopted` 检查将配置指向丢弃端口,区分网络失败和认证失败。 -`agents.lock.json` 每个 auto Agent 已有 `command`、`config_path`、`config_adapter`、`version_args` 等字段。问题是 Python 里又写了一遍,两处会不一致。 - -| 位置 | 硬编码内容 | 性质 | -| --- | --- | --- | -| `installer.py:694-698` | `_next_step` 的五个 Agent 启动命令 | **重复了 lock 的 `command`** | -| `installer.py:1335` | `_restart_hint` 的四个命令名映射 | **重复了 lock 的 `command`** | -| `installer.py:1454-1455` | `backups` 手写 `.codex/config.toml` 与 `.claude/settings.json` | **重复了 lock 的 `config_path`** | -| `installer.py:1137`、`:1375` | 需要 env 文件的 Agent 集合 | 行为未在 lock 声明(第 1 节的缺陷根因) | -| `installer.py:530`、`:1421` | Windows 上 Claude Code 需要 git | lock 有 `windows_prerequisites`,此处未读它 | -| `installer.py:712-724` | `_write_agent_config` 按 adapter 分派 | **合理**:适配器是代码,不是数据 | -| `installer.py:293` | `write_codex_config` 里的 `agent_env_var("codex")` | **合理**:该函数专属 Codex | -| `providers.py:66`、`:178` | `"claude-code"` 判断 Anthropic 协议 | 已有 `ADAPTER_PROTOCOLS`,此处绕过了它 | - -前三项是真正的可维护性问题:同一事实存在两份,改 lock 不会改行为。`:1137` 那项更进一步——它是一个**未被声明的行为**,也正是 Claude Code 失效的原因。 - -新增一个自动配置 Agent 现在要动:lock 一处 + `_write_agent_config` 分派 + `_next_step` + `_restart_hint` + env 文件名单 + `backups`,共 6 处,其中 4 处纯属重复。 - -## 3. 要完成的任务 - -### 任务 1:修 Claude Code 的认证链路(阻塞级)— 已完成 - -lock 里为每个 auto Agent 增 `credential_delivery`(`oneagent_env` / `native_env` / `config_file`),Claude Code 另有 `env_vars` 声明它自己读的四个变量名。`install_many` 与 `activate_agent` 改读 `needs_env_file(meta)`,不再判断 id 集合;`_next_step` 与 `_restart_hint` 也由 lock 推导。 - -实测确认(配置指向 `127.0.0.1:9`,按 `next` 指引启动): +## 审计门禁 +```bash +go test ./internal/config ./internal/install ./internal/app +go run ./cmd/oneagent-rc adopted +go run ./cmd/oneagent-release check release ``` -next: source ~/.oneagent/agents/codex.env && codex - source ~/.oneagent/agents/claude-code.env && claude - -Codex 采用了我们写的 provider(provider: oneagent) -Claude Code 不再出现 Not logged in -``` - -防复发的断言分两处:`test_install_contract.py` 的 `CredentialDeliveryTests` 遍历所有 auto Agent,要求每个在配置后都能从 env 文件或配置文件之一取到密钥;`test_release_policy.py` 要求 lock 里每个 auto Agent 都声明 `command` / `config_path` / `config_adapter` / `credential_delivery`,`native_env` 还必须给出变量名。原先的缺陷正是「没有任何测试问过密钥怎么到达 Agent」。 - - - -让 Claude Code 也拿到 env 文件。lock 里为每个 auto Agent 声明它需要哪些环境变量,`install_many` 与 `activate_agent` 读该声明而不是硬编码集合。 - -Claude Code 的 env 文件应导出 `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_MODEL` / `ANTHROPIC_SMALL_FAST_MODEL`,`_next_step` 相应改为先 source 再启动。`settings.json` 继续写(它承载非认证配置),但不再是认证的唯一途径。 - -必须有一个测试断言「每个自动配置 Agent 都有可用的认证途径」,否则同类缺陷会再次静默通过。 - -### 任务 2:让 lock 成为真正的唯一真源 — 已完成 - -落实情况:`backups` 改为遍历 lock、按各 Agent 的 `config_path` 推导备份 glob,不再手写两条路径;`providers.py` 的 `provider_config_base` 改收推理协议(调用方传 `agent_protocol(adapter)`),两处 `"claude-code"` 字面量比较移除;`_require_prerequisites` 与 `status_payload` 的 Windows 门禁改读 `windows_prerequisites`。`test_release_policy.py` 新增 `LockIsTheSourceOfTruthTests`,遍历 lock 断言备份、Windows 门禁与协议判定都由声明驱动。 - -把重复的三项改为读 lock: - -- `_next_step` 与 `_restart_hint` 用 `meta["command"]`,不再手写命令名。 -- `backups` 用 `meta["config_path"]` 推导备份 glob,不再手写路径。 -- `_require_prerequisites` 与 `status_payload` 读 `meta["windows_prerequisites"]`,不再判断 `agent_id == "claude-code"`。 -- `providers.py` 用 `agent_protocol(adapter)` 判断,不再比较 agent id。 - -目标:新增 Agent 只需改 lock 加一个适配器函数。加一个测试遍历 lock,断言不存在只在 Python 里出现的 Agent 行为。 - -### 任务 3:把「配置后能用」纳入验证 — 已完成 - -落实情况:新增 `scripts/agent_config_adopted_check.py`。其中纯分类器 `classify_adoption` 判别「连接失败 = 配置已采用」与「认证/登录错误 = 配置未采用」,由 `test_rc_scripts.py` 用本轮 Codex 与 Claude Code 两个真实输出在常规 CI 离线覆盖;脚本本体把配置指向丢弃端口 `127.0.0.1:9`、用假 Key,自包含安装并实跑 Agent,无需任何真实 Key,已接入 `release-candidate.yml`(非 Windows)。这把发现本轮缺陷的那道「真实 Key 门槛」从该检查里移除了。 - -`agent_e2e_smoke.py` 已经会实际调用两个 Agent,但它需要真实 Key,本轮缺陷就是在那道门槛之外发现的。补一个不需要 Key 的检查:把配置指向丢弃端口,断言 Agent 报的是连接失败而非认证/配置错误——这恰好能区分本轮两种结果,且能进常规 CI。 - -## 4. 明确不做 - -- 不改 `_write_agent_config` 的 adapter 分派。适配器是代码而非数据,五个函数写的是五种格式,这不是硬编码。 -- 不为 guide-only Agent 建配置或 env 文件。 -- 不扩展到另外三个 Agent 的实测。本轮范围是 Codex 与 Claude Code;但任务 1、2 的实现应当对五个都成立,因为它们要消除的正是「按 id 特判」。 -相关文档:[空白机器可用性验证计划](blank-machine-verification-plan.md)、[产品边界基线](product-boundary-baseline.md)。 +新增自动配置 Agent 时只需更新 `agents.lock.json`、增加对应 Go adapter 和测试,并更新前端生成 binding 需要的公共类型。 diff --git a/docs/config-discovery-plan.md b/docs/config-discovery-plan.md index 6e105f25..fa3c9072 100644 --- a/docs/config-discovery-plan.md +++ b/docs/config-discovery-plan.md @@ -1,149 +1,19 @@ -# 读取用户既有 Agent 配置,与全链路验证环境 +# 读取用户既有 Agent 配置 -> **实施状态(2026-07-29)**:第 2 节全部落地。五个 `read_*_config` 加 `detect_agent_config`,`status_payload` 每个 auto Agent 增 `detected`;前端 `targetSummary` 统一显示逻辑,详情页在覆盖非 OneAgent 配置前预警。`tests/test_config_discovery.py`(21 用例)与 `tests/existing_config_test.sh`(fixture,已接入两个 cleanroom)。真机验证:本机 Codex / Claude Code / OpenCode 的真实配置此前全部显示「未配置」,现在各自端点与模型都能读出并标注来源。 -> -> 第 3 节的容器化 macOS 结论不变:硬件不支持,以 fixture 层替代。 +> 状态:已实施(2026-07-31)。Go `internal/config` 提供只读发现器,Wails status 和 CLI 共用;本文件记录行为而非旧实现路径。 -两个需求:检测不能只看环境、还要看各 Agent 实际配置成什么样;以及在容器里的 macOS 虚拟机中跑全链路。 +## 行为 -第二个需求在当前硬件上不成立,第 3 节给出证据和替代方案。第一个是真实缺口,已实测确认。 +每个 auto Agent 的 status 同时提供: -## 1. 现状:OneAgent 看不见自己没写过的配置 +- OneAgent 自己的 binding(Provider、模型、更新时间)。 +- `detected`:从 Agent 实际配置文件读出的 base URL、模型、是否由 OneAgent 标记、解析错误(不包含 Key)。 -`status_payload()` 对每个 Agent 报四个字段,来源如下: +支持 Codex TOML、Claude settings JSON、OpenCode/Kilo JSON、Aider env 脚本。读取只做行解析或 JSON/TOML 解析,绝不执行配置脚本、修正文件或返回凭据。坏文件只标记 `unreadable`,不会让整个 status 请求失败。Aider 无法可靠区分手写和 OneAgent 形态,因此 `managedByOneAgent` 保守为 false。 -| 字段 | 来源 | 问题 | -| --- | --- | --- | -| `installed` | `runtime.which(command)` | 无 | -| `configured` | `path.exists()` | **只知道文件在,不知道里面是什么** | -| `provider` / `model` / `baseUrl` | `~/.oneagent/agents/.json` | **OneAgent 自己的记账,不是 Agent 的真实配置** | +## 安全约束 -实测:手写一份 Codex 与 Claude Code 配置(模拟用户自己配过、或用别的工具配过),不建任何 OneAgent binding,然后读 status: - -``` -codex configured=True provider=None model=None baseUrl=None -claude-code configured=True provider=None model=None baseUrl=None - -磁盘真实内容 codex -> someone-else / gpt-5-mini / api.other-vendor.com - claude -> api.third-party.com / claude-x -``` - -**`configured=True` 但三个字段全是 `None`。** 界面因此只能显示「未配置」,而磁盘上明明有一份指向别处的配置。三个后果: - -- **总览说谎。** 用户看到「未配置」,实际有配置在生效。 -- **覆盖无预警。** 用户点「应用」会静默盖掉自己手写的 provider(备份有,但界面没提示即将覆盖什么)。 -- **产品定位打折。** [近期工作纪要](recent-work-summary.md) 说它要从「一次性工具」变成「长期管理设备上各个 Agent」,而管理的前提是先看得见。 - -顺带一个更小的问题:`configured` 这个名字对 guide-only Agent 也返回 `path.exists()`,而我们从不为它们写配置——那个 `True` 的含义与 auto Agent 完全不同。 - -## 2. 要做的:配置读取(inspect) - -方向是**每个适配器补一个反向函数**:现在有五个 `write_*_config` 把结构化字段翻译成各 Agent 的格式,加五个 `read_*_config` 把格式翻译回结构化字段。这与既有设计一致——适配器是代码而非数据(见 [配置链路审查](config-chain-audit.md) 第 4 节),所以反向也应是代码。 - -### 2.1 数据形状 - -`status_payload` 的每个 Agent 增一个 `detected` 对象,与 `provider`/`model`/`baseUrl`(OneAgent 记账)并列而不是替换: - -```python -"detected": { - "baseUrl": "https://api.other-vendor.com/v1", - "model": "gpt-5-mini", - "managedByOneAgent": False, # 配置里是否有 OneAgent 写的标记 - "provider": None, # 能反查到内置 Provider 则给 id,否则 None - "unreadable": None, # 解析失败时给原因 -} -``` - -两者并列的理由:不一致本身就是要显示的信息。binding 说 PPIO 而磁盘说别的,意味着用户在 OneAgent 之外改过配置——这正是「长期管理」要告诉用户的事。 - -`managedByOneAgent` 的判据是配置里的既有标记,不需要新增字段:Codex 看有没有 `[model_providers.oneagent]`,OpenCode/Kilo 看 `provider.oneagent`,Claude Code 看 `env` 里四个 `ANTHROPIC_*` 是否齐全。 - -**Aider 是例外,恒为 `false`。** 计划原本写「看脚本里是否是我们的两行 export」,fixture 实测推翻了它:手写的 Aider 脚本与我们写的形态完全一样(都是两行 export),没有任何标记能区分。它的配置又在 `~/.oneagent/` 下——本来就是我们的目录。所以承认无法区分,而不是让一个猜测冒充判据。 - -### 2.2 五个读取函数 - -- `read_codex_config` —— TOML,取 `model_provider` 指向的那个 `[model_providers.*]` 的 `base_url`,以及顶层 `model`。注意**不读 `env_key` 指向的值**(那是密钥)。 -- `read_claude_config` —— JSON,取 `env` 里的 `ANTHROPIC_BASE_URL` 与 `ANTHROPIC_MODEL`。 -- `read_openai_compatible_config` —— JSON,取 `provider..options.baseURL` 与 `model`(形如 `oneagent/`,要剥前缀)。OpenCode 与 Kilo 共用。 -- `read_aider_config` —— shell/PowerShell 脚本,取 `OPENAI_API_BASE`。这个要用行解析而非执行脚本。 -- 未知 adapter 一律返回 `unreadable`,不猜。 - -### 2.3 硬性约束 - -**绝不把读到的密钥放进响应。** 这是最需要小心的一点:五份配置里有三份含密钥明文(Claude 的 `ANTHROPIC_AUTH_TOKEN`、Aider 的 `OPENAI_API_KEY`,以及 OpenCode 若用户手填过 apiKey)。读取函数必须只提取 base URL 与 model,且: - -- `detected` 里没有任何密钥字段,连布尔的 `hasKey` 也先不加(想加要单独评估:它会泄漏「这台机器上有没有配过 Key」)。 -- 读取过程中的异常消息不得包含文件内容片段——`CONFIG_WRITE_FAILED` 现有的错误消息带路径不带内容,沿用同样做法。 -- 加一个测试:构造含 `sk-` 明文的配置,断言 `/api/status` 全文不含它。这类断言 `test_core.py` 已有先例(`test_api_key_reaches_only_the_designated_secret_files`)。 - -**解析失败不能让 status 挂掉。** 一份被改坏的 TOML 现在会让整个 `/api/status` 500,从而整个界面白屏。每个读取函数必须自己吞掉解析异常并返回 `unreadable`,理由与 `write_*` 遇到坏配置时返回 `CONFIG_WRITE_FAILED` 而非静默覆盖一致——只是这里的失败必须是局部的。 - -**只读,不改。** inspect 路径不得触发任何写入,包括不得「顺手修正」格式。 - -### 2.4 界面 - -`AgentManageRow` 与 `AgentDetailPage` 现在显示 binding。改为: - -- 有 binding 且与 detected 一致 → 照现在显示。 -- 有 detected 无 binding → 显示 detected,标注「检测到的配置(非 OneAgent 写入)」。 -- 两者不一致 → 显示 detected 为主,提示 binding 记录的值不同。 -- `unreadable` → 显示「配置无法解析」并给出路径,不显示猜测值。 - -详情页的「应用」按钮在 detected 存在且非 OneAgent 管理时,应当说明即将覆盖什么。这是把「无预警覆盖」变成有预警。 - -### 2.5 测试 - -- 五个适配器各一组「写入→读回」往返用例,断言读回的 baseUrl 与 model 等于写入值。这条最有价值:它同时锁住两个方向。 -- 手写的、非 OneAgent 格式的配置能被读出正确值(就是第 1 节那个实测场景,固化下来)。 -- 坏配置返回 `unreadable` 且 `/api/status` 仍返回 200。 -- 密钥不出现在响应里。 -- guide-only Agent 不产生 `detected`。 -- `installer.py` 100% 分支、零 partial 的门禁不变。 - -## 3. 关于「Docker 里的 macOS 虚拟机」 - -**这台机器上不可行**,原因是硬件而非配置: - -``` -主机架构 arm64(Apple Silicon) -kern.hv_support 1(主机支持虚拟化) -容器内 /dev/kvm No such file or directory -容器 x86 支持 有(用户态转译) -``` - -`sickcodes/docker-osx` 一类方案的原理是容器内 QEMU 加载 **x86_64** macOS 镜像,前提是 `/dev/kvm` 可用。两个条件都不满足: - -1. **Docker Desktop 的 LinuxKit VM 不暴露嵌套虚拟化**,容器里没有 `/dev/kvm`。这不是权限问题,是 Docker Desktop on macOS 的架构决定的。 -2. **arm64 主机上没有可用的 macOS 虚拟磁盘镜像。** Apple Silicon 版 macOS 只能通过 Apple 自己的 Virtualization.framework 跑,不能被 QEMU 当作 x86 客户机加载。容器里的 x86 支持是用户态二进制转译,跑不动一个内核。 - -即使强行在 x86 主机上做,还有两个问题值得先说清:**Apple 许可证只允许在 Apple 硬件上虚拟化 macOS**,容器化 macOS 镜像的分发本身处在灰区——这与 [产品边界基线](product-boundary-baseline.md) 对分发合规的要求不一致,不适合成为项目的标准验证设施。 - -### 替代方案:已有的两层,加一层缺口 - -需求的实质是「全链路在干净 macOS 上可重复验证」。现有设施已经覆盖了大部分: - -| 层 | 设施 | 覆盖 | -| --- | --- | --- | -| Linux 断网容器 | `scripts/test_docker_cleanroom.sh` | 契约测试、`install.sh`、GUI 冒烟、浏览器 e2e、策略扫描 | -| 真实 macOS | `tests/macos_cleanroom_test.sh` | 干净 HOME、权限、打包二进制、真实 HOME 零污染快照 | -| 真实安装 | `tests/real_install_test.sh` | 真装两个 Agent、PATH、版本、双 registry | - -**真实缺口不是「缺一个 macOS 环境」,而是这三层都不覆盖「用户已有配置」这个起点。** 它们都从干净 HOME 开始,所以第 2 节要读的那种「外部写入的配置」在任何 cleanroom 里都不会出现。 - -所以要加的是一层 fixture,而不是一个虚拟机: - -- 在 `tests/` 下建一组**既有配置样本**(手写风格的 Codex TOML、第三方 Claude settings、含额外用户字段的 OpenCode JSONC、坏格式各一份)。 -- macOS cleanroom 与容器 cleanroom 各增一个阶段:把样本放进干净 HOME,断言 status 能读出正确的 detected 值、坏样本报 `unreadable` 而非 500、写入后用户的额外字段仍在。 -- 这一层是离线纯文件操作,两个 cleanroom 都能跑,也能进常规 CI。 - -如果确实需要一台干净 macOS 做人工验收,可行路径是本机的 Virtualization.framework(Tart、UTM 或 `macosvm`),而非 Docker。那属于本地开发环境搭建,不该进 CI——CI 里的 macOS 由 GitHub Actions 的 `macos-14` runner 提供,已经在用。 - -## 4. 明确不做 - -- **不为读取配置引入任何写入。** inspect 只读。 -- **不把密钥或其存在性放进 API 响应。** 只提取 base URL 与 model。 -- **不猜未知格式。** 未知 adapter 或解析失败一律 `unreadable`。 -- **不做容器化 macOS。** 硬件不支持,且分发合规不成立。 -- **不改 `configured` 字段的现有语义**(避免破坏前端契约),新增 `detected` 与之并列;若将来要收敛,另开一次改动。 - -相关文档:[配置链路审查](config-chain-audit.md)、[空白机器可用性验证计划](blank-machine-verification-plan.md)、[产品边界基线](product-boundary-baseline.md)。 +- 检测响应不含 API Key、Token 或 Key 是否存在的推断。 +- 错误只包含路径和解析诊断,不回显文件内容。 +- 覆盖非 OneAgent 配置前,前端显示警告并保留备份。 +- guide-only Agent 不生成 `detected`。 diff --git a/docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md b/docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md index 0d9c914c..2ce076aa 100644 --- a/docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md +++ b/docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md @@ -1,118 +1,29 @@ -# ADR-003:三平台 Python 安装核心与版本锁定发行策略 +# ADR-003:三平台运行时与版本锁定发行策略(已废弃) -## Status +> 状态:**Superseded**(2026-07-31)。当前实现和发行规则由 [ADR-007](ADR-007-wails-v3-go-migration.md)、[ADR-005](ADR-005-channel-neutral-distribution-and-compliance.md) 和 `cmd/oneagent-release` 定义。本文件只保留历史背景,不是安装或发布操作指南。 -Accepted +## 历史背景 -## Date +早期 OneAgent 使用跨平台脚本和 Python 标准库实现 Agent catalog、配置适配、安装编排和本地 HTTP GUI。该方案曾强调三平台路径、权限、锁定版本、npm/uv allowlist、完整错误码和 cleanroom 证据。 -2026-07-22 +这些产品约束仍然有效,但实现已经迁移为: -## Scope Update +- Go catalog、provider、install、config、profile、securefs 和 process 包。 +- `cmd/oneagent` 纯 Go CLI 与 `cmd/oneagent-desktop` Wails 应用。 +- React 通过生成的 Wails bindings 调用 Go service。 +- `cmd/oneagent-release` 生成原生 Wails/Go 包、manifest、SHA-256 和第三方 notices。 +- `cmd/oneagent-rc` 与 `cmd/oneagent-provider-smoke` 承担发行候选检查。 -Python 共用内核、版本锁定、配置适配和结构化错误决策继续有效。当前渠道分发范围和发行门禁已由 [ADR-005](ADR-005-channel-neutral-distribution-and-compliance.md) 更新,不再以四平台同时发布作为当前产品阶段门槛。 +## 仍保留的产品约束 -## Context +- Agent 包不进入 OneAgent 发行包;安装只能来自 lock 声明的官方源或用户明确选择的 HTTPS 镜像。 +- 子进程使用参数数组、受控环境和超时;禁止 shell 拼接和未审查的下载管道。 +- API Key 不进入 profile、日志、URL、命令行、React 状态或发行附件。 +- 配置写入必须备份、原子替换并收紧 Unix mode/Windows ACL。 +- Codex、Claude Code 和 OpenAI-compatible Agent 按实际协议分别探测。 +- Wails Alpha 阶段只发布 `technical-preview-unsigned`;Stable 需要单独的签名、公证和原生验证证据。 +- Aider 是可选外部上游例外:用户选择安装时需要已有 Python 3.12,OneAgent 不捆绑或下载该运行时。 -OneAgent 的产品基线要求 macOS、Windows 和 Linux 在发布前分别验证,但早期实现以 Bash 安装脚本和浏览器 GUI 为中心,无法为 Windows 原生路径、ACL、PowerShell 环境文件和跨平台打包提供同一套行为契约。 +## 迁移记录 -Agent 的上游安装版本持续变化。默认安装 `latest` 会使测试、配置兼容性和发行包验收无法复现,也无法准确生成第三方许可证与版本清单。 - -React 前端需要稳定的状态、错误和安装结果 API。如果前端迁移早于安装核心和 API 契约冻结,页面会固化尚未闭合的后端语义。 - -## Decision - -### 平台与架构(内核兼容设计,不是当前分发门槛) - -- Python 核心保留 macOS、Windows 和 Linux 的路径、权限和前置条件适配设计;当前发行包只声明实际构建和验证过的目标环境。 -- 不以 macOS、Windows 和 Linux 同时构建或验收作为当前产品阶段门槛;每个实际发布的平台仍须在对应操作系统原生构建,并有 `ci.yml` cleanroom 作业或 Release Candidate 流程的验收证据。 -- 各平台最低目标:macOS 13+(arm64/x64)、Windows 10 22H2 / Windows 11(x64)、Ubuntu 22.04+ 或兼容 glibc 环境(x64)。产物声明的目标环境不得低于上述最低版本。 -- 检测、版本校验、前置条件、安装编排、备份、配置合并、权限和环境摘要统一由 Python 核心实现。 -- `scripts/install.sh` 和 `scripts/install.ps1` 只负责定位 Python 并转发参数;本地 GUI 直接调用同一个 Python 核心。 -- Windows 使用 `%USERPROFILE%` 对应的原生用户目录,不写 WSL HOME,不自动调用 `wsl.exe`。 - -### Agent 范围 - -自动配置范围固定为五个 Agent: - -1. Codex -2. Claude Code -3. OpenCode -4. Kilo CLI -5. Aider - -其他 catalog Agent 保持 `guide-only`。OneAgent 不猜测或修改没有稳定公开配置合约的私有状态文件。 - -### 安装与版本 - -- 默认安装版本来自受版本控制的 `agents.lock.json`。 -- 每个自动配置 Agent 必须显式声明包管理器、包名、版本、完整性信息(适用时)、版本检查命令、支持平台、配置适配器、官方来源和许可证地址;不能只依赖 Agent ID 的隐式分支。 -- 只允许 manifest 中声明的 npm 或 uv tool 安装命令,subprocess 必须使用参数数组且禁止 `shell=True`。Aider 使用隔离的 uv tool 环境,不调用系统级 pip。 -- `--latest` 是用户显式选择的高级选项,不进入默认流程、PR 测试或发行验收。 -- 缺少 npm、uv、Python 3.12、Git for Windows 等前置条件时返回 `PREREQUISITE_MISSING`。Aider 安装固定传入 `--no-python-downloads`,不自动安装语言运行时或 Git Bash。 - -### 密钥与本地状态 - -- API Key 只写入对应的本地密钥文件或 Agent 配置;Unix 目录使用 `0700`、文件使用 `0600`,Windows ACL 只允许当前用户和 SYSTEM。 -- 权限设置失败时配置失败,不静默降级。 -- Key 不进入命令行、URL、日志、环境摘要、React reducer、测试报告或遥测。 -- `~/.oneagent/profile.json` 只保存 schema version、Provider、Base URL、模型、Agent、配置模式和激活时间。 - -### Provider 协议 - -- PPIO/Novita 的 OpenAI-compatible base 用于模型列表、Chat Completions、OpenCode、Kilo CLI 和 Aider。 -- Claude Code 对内置 Provider 使用各自公开的 Anthropic-compatible base;Custom 显式覆盖由用户负责协议兼容。 -- Codex 当前配置使用 Responses 协议。Chat Completions 探测成功不能证明 Codex 可用,必须在 Release Candidate 中测试 `/v1/responses` 和真实 Codex 首次请求。 -- 如果某个内置 Provider 不支持 Agent 当前所需协议,该组合必须明确降级为不支持或 `guide-only`。V1 不通过本地协议网关隐藏兼容性缺口。 - -### API 与前端门槛 - -- `/api/status` 只做向后兼容的字段追加;错误响应保留 `error/message/status` 语义并增加稳定 `error_code` 与 `retryable`。 -- `/api/install` V1 保持同步,前端只显示不定进度和最终逐 Agent 结果。 -- 所有 POST 必须同时通过随机 HttpOnly 会话 Cookie 与 localhost Origin 校验。 -- React 七页流程只能建立在 Python 核心、三平台适配和 API contract tests 全绿的冻结契约上。 - -### 发行 - -- 默认发布源码 ZIP 与 PyInstaller onedir 压缩包,不发布单文件自解压版本。 -- 每个产物必须明确记录实际构建环境、架构和验证范围;未验证的平台不得出现在兼容性承诺中。 -- GitHub、官网、网盘和企业云盘只作为同一官方构建的镜像渠道,不维护渠道专属包体。 -- 未签名产物只能标记为 `technical-preview-unsigned`。 -- Stable 额外要求 macOS 签名/公证和 Windows Authenticode,由 `scripts/build_release.py` 对产物本身做签名验证强制(环境变量不构成证据)。该门槛未被任何决策取代;当前阶段只是不发布 Stable。 -- 每个产物必须附 SHA-256、锁定版本清单和第三方许可证清单。 -- `release-candidate.yml` 的定义门禁是真实验收:四平台真实安装五个锁定 Agent,并用受保护 Provider Key 执行真实协议冒烟。在 CI Secret 配置完成前它尚未运行;常规 CI 以包体完整性、临时 HOME 启动、许可证、secret 扫描和本地 Mock 流程为门禁。 - -## Alternatives Considered - -### 继续扩展 Bash,并为 Windows 单独维护 PowerShell 实现 - -- 优点:短期改动较少。 -- 缺点:核心逻辑会形成两套实现,备份、权限、错误码和配置合并容易漂移。 -- 结论:拒绝。 - -### 先完成 React,再逐步替换后端 - -- 优点:更早看到新界面。 -- 缺点:前端会依赖未冻结的安装语义,导致重复重构和错误状态映射。 -- 结论:拒绝。 - -### 默认安装上游 latest - -- 优点:用户总能获得最新功能。 -- 缺点:测试不可复现,可能在无代码变化时产生安装回归,也无法准确审核许可证和版本。 -- 结论:拒绝;保留显式 `--latest`。 - -### Electron 或 Tauri 桌面壳 - -- 优点:更接近原生桌面分发。 -- 缺点:引入额外运行时、签名面和构建复杂度,当前本地浏览器 GUI 已能满足流程需求。 -- 结论:V1 不采用。 - -## Consequences - -- 源码模式仍需要本机 Python;发布包通过 PyInstaller 携带运行时,不要求终端用户安装 Python。 -- 安装核心和测试规模增加,但三平台行为可由同一契约验证。 -- React 不直接执行命令或写配置,只消费本地 API。 -- 每次更新锁定 Agent 版本都必须同步验证真实安装、版本输出、许可证和发行清单。 -- 同一个模型 ID 跨 OpenAI、Anthropic 和 Responses 协议的可用性不能由 `/v1/models` 推断,必须进入 Provider/Agent 兼容性矩阵。 -- WSL 管理、自动更新、后台服务、统一网关和遥测需要单独 ADR。 +Python 实现、Python 测试、PyInstaller/wheel/setuptools 配置和相关工作流已删除。新的验收清单见 [Wails v3 迁移收尾计划](../wails-v3-migration-plan.md)。 diff --git a/docs/decisions/ADR-004-per-agent-protocol-verification.md b/docs/decisions/ADR-004-per-agent-protocol-verification.md index d05ec6bc..137d220e 100644 --- a/docs/decisions/ADR-004-per-agent-protocol-verification.md +++ b/docs/decisions/ADR-004-per-agent-protocol-verification.md @@ -10,7 +10,7 @@ Accepted ## Context -[ADR-003](ADR-003-three-platform-python-core-and-release-policy.md) 冻结了五个自动配置 Agent 与配置适配器映射,但连接测试始终只发一种请求:`POST /v1/chat/completions`。 +[ADR-003](ADR-003-three-platform-python-core-and-release-policy.md) 冻结了五个自动配置 Agent 与配置适配器映射,但早期连接测试始终只发一种请求:`POST /v1/chat/completions`。 这与 Agent 配置后的真实行为不一致: @@ -41,7 +41,7 @@ README 早已声明"同一个模型 ID 不一定同时兼容 OpenAI、Anthropic ### 协议映射 -每个 Agent 的推理协议由 `agents.lock.json` 的 `config_adapter` 推导,映射表位于 `oneagent/catalog.py`,与配置写入使用同一来源,避免两处漂移。未登记的适配器回退为 OpenAI-compatible。 +每个 Agent 的推理协议由 `agents.lock.json` 的 `config_adapter` 推导,映射表位于 `internal/catalog`,与配置写入使用同一来源,避免两处漂移。未登记的适配器回退为 OpenAI-compatible。 ### 验证时机 diff --git a/docs/decisions/ADR-005-channel-neutral-distribution-and-compliance.md b/docs/decisions/ADR-005-channel-neutral-distribution-and-compliance.md index 54261b20..2982d7c5 100644 --- a/docs/decisions/ADR-005-channel-neutral-distribution-and-compliance.md +++ b/docs/decisions/ADR-005-channel-neutral-distribution-and-compliance.md @@ -30,14 +30,14 @@ OneAgent 采用“一个官方构建、多个同包镜像”的分发模型: - 默认只分发 OneAgent 自有代码和完成许可证义务的运行依赖。 - 不分发第三方 Agent 二进制,不把官方可下载等同于允许再分发。 - Agent 安装继续采用官方源、授权镜像、用户手动安装和 `guide-only` 的降级顺序。 -- 不捆绑 Node.js、Python、Git Bash、VPN、代理、共享 Key 或第三方配置工具。 +- 不捆绑 Node.js、Git Bash、VPN、代理、共享 Key 或第三方配置工具;Aider 的 Python 3.12 仅是用户选择 Aider 时的外部上游前置条件。 ### 当前发行范围 - 当前目标是可直接下载和运行的技术预览二进制包。分渠道分发不要求四平台全部齐备,但每个实际发布的平台仍须在对应操作系统原生构建,并以 `ci.yml` 的 cleanroom 作业或 Release Candidate 流程作为平台验收证据。 - 每个产物只声明其实际构建和验证过的目标环境,不对未构建环境作兼容承诺。 - 平台签名、公证、商店分发和自动更新不属于当前阶段。 -- 未完成更高等级发行门禁前继续使用 `technical-preview-unsigned`,不使用 Stable 标签。Stable 门槛本身仍然有效:`scripts/build_release.py` 会对声明为 Stable 的产物执行产物级签名验证(macOS `codesign` / Windows Authenticode),签名工具链缺失时 fail-closed;当前阶段只是不发布 Stable。 +- 未完成更高等级发行门禁前继续使用 `technical-preview-unsigned`,不使用 Stable 标签。Stable 门槛本身仍然有效,并由 `cmd/oneagent-release` 的后续签名阶段对产物执行验证(macOS `codesign` / Windows Authenticode);当前阶段只是不发布 Stable。 ### 合规门禁 @@ -46,7 +46,7 @@ OneAgent 采用“一个官方构建、多个同包镜像”的分发模型: ## Relationship To Previous Decisions - ADR-002 的网络访问、共享 Key、第三方 Agent 和配置工具边界继续有效。 -- ADR-003 的 Python 共用内核、版本锁定和配置适配策略继续有效。 +- ADR-003 的版本锁定、配置适配和权限约束继续有效;其旧运行时实现已由 ADR-007 的 Go/Wails 路径取代。 - ADR-003 中“四平台同时作为当前发行门槛”的部分被本 ADR 收窄:解除“四平台必须同时齐备”的耦合,不解除任何单平台的原生构建与验收要求;平台矩阵可以作为后续扩展,不再阻塞当前渠道分发目标。 - ADR-004 的按 Agent 协议验证继续有效。 @@ -99,4 +99,3 @@ OneAgent 采用“一个官方构建、多个同包镜像”的分发模型: - 自动安装来源位于固定 allowlist。 - 技术预览状态、目标环境和已知限制明确。 - 渠道负责人、链接、上传时间和撤回状态可追溯。 - diff --git a/docs/decisions/ADR-006-multi-profile-and-long-term-management.md b/docs/decisions/ADR-006-multi-profile-and-long-term-management.md index 3aa5c37c..f1dde478 100644 --- a/docs/decisions/ADR-006-multi-profile-and-long-term-management.md +++ b/docs/decisions/ADR-006-multi-profile-and-long-term-management.md @@ -2,7 +2,7 @@ ## Status -Accepted +Implemented ## Date @@ -45,7 +45,7 @@ OneAgent 目前是一次性向导:激活完成即结束,`~/.oneagent/profile - 向导激活(`install_many` 收尾的 `write_profile`)变为"更新或创建当前激活 profile":同一 `provider + model` 沿用原 id 并保留 `agent_ids` 合并语义,否则新建。 - 切换 = 用另一组参数重写同一批配置文件,**完全复用现有写入链路**(`_write_agent_config` 分派 + `atomic_write` + 备份),不引入新的写入逻辑。 - `POST /api/activate` 的响应必须携带逐 Agent 的**重启指引**(采纳 CC Switch 教训:Agent 不自动重载配置),而不是只返回"已切换"。 -- 新增端点一律复用 `server.py` 现有 POST 校验(Origin 白名单 + HttpOnly 会话 Cookie),不另开通道。Key 经请求体传入、只落 `secrets/`,与现有 `/api/install` 的安全姿态一致。 +- Profile 操作统一复用 Go `ProfileService` 和 `securefs` 写入边界,不新增 HTTP 通道。Key 只落 `secrets/`,binding 返回公开摘要。 ### CLI @@ -96,5 +96,5 @@ OneAgent 目前是一次性向导:激活完成即结束,`~/.oneagent/profile - `profile.json` schema 变更,迁移是唯一的数据风险点:备份先行 + 测试固定。 - `status_payload` 增加 `profiles` / `activeProfile` 字段,必须同步 `frontend/src/types/api.ts`(传输契约规则)。 - CC Switch 文档的推荐顺序需要调整:OneAgent 内置切换为主路径,CC Switch 作为可选下游。 -- 覆盖率门禁不变:`installer.py` 100% 分支、整体 ≥85%、前端 `src/api`/`src/state` ≥85%;新端点与迁移路径都要配测试。 +- Go profile/config tests、React state tests 和 Wails binding tests 覆盖新端点与迁移路径。 - 备份回滚 UI、per-agent profile、`--wire-shell` 需要各自的后续评估,其中回滚 UI 需要新端点。 diff --git a/docs/decisions/ADR-006-public-site-and-generated-release-index.md b/docs/decisions/ADR-006-public-site-and-generated-release-index.md index d4356ab5..cee19699 100644 --- a/docs/decisions/ADR-006-public-site-and-generated-release-index.md +++ b/docs/decisions/ADR-006-public-site-and-generated-release-index.md @@ -1,23 +1,23 @@ -# ADR-006:独立公开站与机器生成发行索引 +# ADR-006:独立公开站与 GitHub Release 事实源 -- 状态:Accepted +- 状态:Accepted(2026-07-31 修订) - 日期:2026-07-28 ## 背景 -OneAgent 的 React 前端是随本地 Launcher 打包的操作界面。公开下载、搜索内容、发行证据和企业服务需要静态可索引页面,两者的安全、缓存、路由和发布周期不同。手工维护下载页版本与哈希会产生事实漂移。 +OneAgent 的 React 前端是随本地 Wails Launcher 打包的操作界面。公开下载、搜索内容、发行证据和企业服务需要静态可索引页面,两者的安全、缓存、路由和发布周期不同。站点构建直接读取 Release API 和仓库 JSON,保持独立发布周期。 ## 决策 1. 在同一仓库维护独立 `site/` Astro 静态站,不把营销路由加入本地 Launcher。 -2. 平台 manifest 与 SHA256SUMS 保持构建事实源;人工渠道状态放入 `distribution/channels.json`。 -3. 通过 `scripts/build_release_index.py` 验证并生成公开 `/release-index.json`,下载页面只消费该数据。 -4. Agent 兼容目录由 `agents.lock.json` 生成只读投影;Provider 商业披露放在独立数据文件,不能影响 rank 或技术结论。 +2. App 工作流只创建 GitHub Release;站点工作流由站点变更、Release 发布或人工操作独立触发。 +3. 公开版本、发布日期、下载资产、大小和摘要只读取 GitHub Releases API,不读取本地 App 构建目录,也不维护手工回退版本。 +4. Agent 兼容目录直接读取 `agents.lock.json`;Provider 运行时端点和商业披露统一读取 `providers.lock.json`,商业字段不能影响 rank 或技术结论。 5. 网站默认不加载客户端分析脚本;Launcher 保持默认无遥测。 ## 后果 - Launcher 无需为官网 SEO、域名或外部托管做重构。 -- 发布站点必须拿到受验证的原生 artifact 才能显示下载按钮。 -- GitHub Pages、自有对象存储或其他镜像可以更换,但同版本包体与 SHA-256 不得变化。 -- 网站新增独立 Node 依赖和 CI 作业;源代码包包含网站源码但不包含 `node_modules`、`site/dist` 或复制后的下载目录。 +- Draft 和本地构建不会出现在官网;只有已发布 GitHub Release 能产生版本和下载按钮。 +- GitHub Pages 发布不构建 App,App 发布也不构建或部署 Pages。 +- 网站只需要 Node 工具链;App 源代码包不再携带站点源码。 diff --git a/docs/decisions/ADR-007-wails-v3-go-migration.md b/docs/decisions/ADR-007-wails-v3-go-migration.md new file mode 100644 index 00000000..86f42da7 --- /dev/null +++ b/docs/decisions/ADR-007-wails-v3-go-migration.md @@ -0,0 +1,44 @@ +# ADR-007: Wails v3 Desktop Shell and Go Core Migration + +- Status: Implemented (2026-07-31) +- Date: 2026-07-30 +- Supersedes: the Python-core, localhost-HTTP and PyInstaller decisions in ADR-003 only + +## Context + +The migration plan called for a Wails v3 desktop shell, a transport-independent +Go core, and a separate headless CLI. Wails v3 is still Alpha, so the shipped +channel remains an unsigned technical preview. + +## Decision + +1. The migration line uses Go 1.26+ and pins Wails v3 to + `v3.0.0-beta.2` for the initial native spike. The matching CLI uses the + same module version. The browser runtime candidate is pinned separately to + `3.0.0-alpha2.117`, the version shipped by that Wails module and present in + the frontend lockfile used by the production bundle. +2. `agents.lock.json` remains the only hand-edited Agent catalog source. The + root Go package embeds that file once, and `internal/catalog` parses the + embedded bytes for both the CLI and desktop shell. +3. `internal/app`, `internal/catalog`, `internal/platform`, + `internal/errors`, and `internal/binding` are the shared production core. + The native Wails implementation is enabled explicitly with the `wails` build + tag, while the headless CLI remains dependency-free from Wails/GTK. +4. The first Wails shell registers only `StatusService`, `ProviderService`, + `AgentService`, and `ProfileService`. It does not configure `Route` or + `RawMessageHandler`, and it does not open a business HTTP listener. +5. The Go/Wails path is now the only production implementation. The old source, + tests, packaging metadata and release scripts were removed after the Go + fixtures, bindings, cleanrooms and release checks passed. Shell wrappers remain + only as thin CLI forwarding compatibility layers. + +## Consequences + +- The Go status/catalog path and the full release toolchain can be tested without + a Python installation. +- Native Wails builds currently require the platform WebView toolchain and a + generated frontend bundle; the default `go test ./...` does not link either. +- The desktop UI and headless CLI call the same Go use cases; there is no HTTP or + legacy runtime fallback. +- Updating Wails or the runtime requires rerunning binding generation and the + four-target native spike before changing the pins. diff --git a/docs/distribution-compliance-policy.md b/docs/distribution-compliance-policy.md index 625af7c7..11028968 100644 --- a/docs/distribution-compliance-policy.md +++ b/docs/distribution-compliance-policy.md @@ -54,7 +54,7 @@ withdrawal_reason 允许进入 OneAgent 发行包的内容: -- OneAgent 自有代码、构建后的 React 静态资源和 Python 运行组件。 +- OneAgent 自有 Go 代码和构建后的 React 静态资源。 - OneAgent 自有图标、文档和配置模板。 - 许可证明确允许再分发,且已经完成对应义务的第三方依赖。 - `agents.lock.json`、官方安装入口和手动配置说明。 @@ -68,7 +68,7 @@ withdrawal_reason 以下内容不得进入二进制包、源码包、网盘附件、发行说明或自动化脚本: - 未获再分发授权的 Codex、Claude Code、Cursor、Kiro、OpenClaw、Hermes 或其他第三方 Agent 二进制。 -- 从 npm、pip、uv、GitHub 或官网下载后直接复制进包内,但没有完成许可证审查的软件。 +- 从 npm、uv、GitHub 或其他来源下载后直接复制进包内,但没有完成许可证审查的软件。 - 修改、破解、补丁化或绕过签名的第三方软件。 - VPN、代理、节点订阅、机场、专线或其他绕过网络限制的工具和配置。 - 共享账号、共享 API Key、长期 Token、Cookie、验证码处理或批量注册工具。 @@ -93,7 +93,7 @@ Agent 获取顺序固定为: - 命令以参数数组执行,禁止 `shell=True` 和动态拼接 shell 命令。 - 安装前显示软件名称、来源、版本和将执行的动作,并由用户确认。 - API Key、Token 和账号信息不得出现在命令行参数中。 -- 不自动安装 Node.js、Python、Git Bash、VPN 或系统级网络组件。 +- 不自动安装 Node.js、Git、VPN 或系统级网络组件。Aider 的外部 Python 3.12 只在用户明确选择 Aider 时由上游安装流程自行要求,绝不进入 OneAgent 包体。 - 不执行未经固定和审查的 `curl | bash`。 官方来源不可达时,只能报告不可达并提供手动安装入口。OneAgent 不配置代理,不提供绕过网络限制的说明,也不把第三方二进制转存到网盘作为临时替代。 diff --git a/docs/frontend-component-redesign-plan.md b/docs/frontend-component-redesign-plan.md index 690690cb..3c58c99a 100644 --- a/docs/frontend-component-redesign-plan.md +++ b/docs/frontend-component-redesign-plan.md @@ -1,255 +1,26 @@ -# OneAgent React 前端实现与发布门禁 +# React 前端实现与发布门禁(已实施) -## 文档状态 +> 本文原为前端重构计划,现转为当前实现摘要。历史 PyInstaller、解释器测试和旧 HTTP 命令已删除。 -- 状态:React 七页 GUI、Python API、安全边界和本机浏览器验收已实现。 -- 更新日期:2026-07-27。 -- 本机验证:macOS arm64。 -- 门禁状态(按 [ADR-005](decisions/ADR-005-channel-neutral-distribution-and-compliance.md) 更新):四平台目标系统 CI 已实现,但不再要求同时齐备;每个实际发布平台仍须原生构建并有 cleanroom 验收证据。仍未完成:五个真实 Agent 安装、PPIO/Novita 真实协议请求(`release-candidate.yml` 已定义但尚未运行)。macOS/Windows 签名属于仍然有效的 Stable 门槛,当前阶段不走 Stable。 -- 架构依据:[ADR-003](decisions/ADR-003-three-platform-python-core-and-release-policy.md)。 +## 当前实现 -本文不再描述尚未开始的 React 迁移,而是固定当前实现、组件边界、测试口径和后续发布条件。 +- React 19 + TypeScript + Vite 构建 `frontend/dist`。 +- 页面通过 `frontend/src/backend/wails.ts` 调用生成 bindings。 +- Vitest 覆盖 backend adapter、state、页面和安全字段。 +- Playwright 使用 Wails `server,e2e` build tag;生产桌面构建不使用 server。 +- `cmd/oneagent-release` 在打包前检查 source map、远程资源和 secret,并将 Go/Wails/React 版本写入 manifest。 -## 当前技术栈 - -| 层 | 实现 | 锁定版本/约束 | -| --- | --- | --- | -| UI | React + TypeScript | React `19.2.8`,TypeScript `7.0.2` | -| 构建 | Vite | `8.1.5`,生产 source map 关闭 | -| 路由 | React Router Hash Router | `7.18.1` | -| 状态 | `useReducer` + Context + secret ref | Key 不进入 reducer | -| 图标 | `lucide-react` | `1.25.0`,不伪造 Agent Logo | -| 单元测试 | Vitest + Testing Library | Vitest `4.1.10` | -| E2E | Playwright Chromium | `1.61.1` | -| 本地服务 | Python 3.12 标准库 HTTP Server | 仅监听 `127.0.0.1` | -| 安装执行 | `oneagent.installer` | Bash/PowerShell/GUI 共用 | -| 发行 | PyInstaller onedir | `6.21.0`,目标系统原生构建 | - -终端用户运行打包版不需要 Node.js 或系统 Python。源码入口要求 Python 3.12+;前端开发要求 Node.js 22。 - -## 运行架构 - -```mermaid -flowchart LR - Browser["React Hash Router"] --> Client["Typed API Client"] - Client --> Server["Python localhost Server"] - Server --> Installer["Python Installer Core"] - Server --> Provider["Provider Probe / Models"] - Installer --> Files["Agent Config / Backup / Profile"] - Installer --> PackageManager["Allowlisted npm / uv tool"] -``` - -职责边界: - -- React 只维护流程、表单、非敏感状态和最终结果。 -- API Key 只存在于密码输入 DOM、`useRef` 和当前请求体中。 -- Python 负责输入校验、Provider URL、安装、备份、配置合并、权限、脱敏和 profile。 -- Bash 与 PowerShell 只做 Python 版本检查和参数转发。 -- guide-only Agent 不进入自动安装和私有配置适配器。 - -## 实际目录结构 - -```text -frontend/ - index.html - package.json - package-lock.json - vite.config.ts - playwright.config.ts - e2e/ - wizard.spec.ts - src/ - main.tsx - App.tsx - api/ - client.ts - client.test.ts - components/ - AppWindow.tsx - NavigationSidebar.tsx - PageScaffold.tsx - SetupStepper.tsx - AgentRow.tsx - ChoiceRow.tsx - ProviderSegment.tsx - SecureKeyField.tsx - ConnectionStatus.tsx - ModelPicker.tsx - ReviewGroup.tsx - AgentProgressRow.tsx - LogDisclosure.tsx - EnvironmentSummary.tsx - StatusBadge.tsx - pages/ - AgentSelectionPage.tsx - ConfigModePage.tsx - ProviderKeyPage.tsx - ModelSelectionPage.tsx - ReviewPage.tsx - ActivationPage.tsx - EnvironmentOverviewPage.tsx - state/ - WizardContext.tsx - wizardReducer.ts - *.test.tsx - styles/ - tokens.css - base.css - app.css - types/ - api.ts -``` - -组件按职责拆分,七个页面没有重新合并进单一表单组件。路由守卫会阻止跳过 Agent、配置方式或模型选择的非法导航。 - -## 页面与状态 - -| 页面 | 主要组件 | 关键规则 | -| --- | --- | --- | -| Agent | `AgentRow`、分类折叠、安装开关 | Agent 多选;至少一个;展示 installed/configured/canInstall | -| 配置方式 | `ChoiceRow` | Provider 模式或 existing-account;后者跳过步骤 3、4 | -| Provider/Key | `ProviderSegment`、`SecureKeyField`、`ConnectionStatus` | PPIO/Novita/Custom;注册入口;Key 不持久化 | -| 模型 | `ModelPicker` | `/v1/models` 成功选首项;失败回退手动输入 | -| 确认 | `ReviewGroup` | 展示实际路径、备份、Provider、模型和 guide-only 项 | -| 执行 | `AgentProgressRow`、`LogDisclosure` | 同步请求显示不定进度;按 Agent 最终状态;失败项单独重试 | -| 总览 | `EnvironmentSummary` | 从无 Key 的 profile 恢复 Provider、模型和 Agent 摘要 | - -`WizardState` 只保存:Agent、配置方式、Provider ID、Custom URL、是否存在 Key、模型、连接状态、模型列表和安装结果。它不保存 Key 内容。 - -单 Agent 重试通过 `profile_agents` 把完整选择集合传给 Python,避免重试成功后覆盖之前已完成 Agent 的环境摘要。 - -## 视觉与响应式 - -- 浅色 Native Utility Split View;标题栏 52px,桌面侧栏 232px,底部操作区 64px。 -- 页面背景 `#F5F5F7`,主表面 `#FFFFFF`,系统蓝 `#007AFF`,成功绿 `#34C759`。 -- 控件圆角 6px,分组 8px,窗口 12px;不使用营销 Hero、装饰渐变球或卡片堆叠。 -- 字体使用系统字体栈,`letter-spacing: 0`,字号不随 viewport 宽度缩放。 -- 日志默认折叠,只有展开后使用深色代码材质。 -- 支持 `prefers-reduced-motion` 和可见键盘焦点。 - -验收 viewport: - -| Viewport | 结果 | -| --- | --- | -| 1440×900 | 七页通过,无横向溢出 | -| 1280×800 | 七页通过,无横向溢出 | -| 1024×720 | 七页通过,侧栏收窄,主内容独立滚动 | - -当前不提供手机布局,最低支持窗口为 1024×720。 - -## API 与本地安全边界 - -- `GET /api/status`:无需会话,返回 `apiVersion`、平台、能力、catalog、路径和可选 environment。 -- `POST /api/probe`:Chat Completions 最小请求。 -- `POST /api/models`:模型列表和手动输入回退。 -- `POST /api/install`:同步执行,返回 per-agent result。 -- `POST /api/open-register`:仅允许 PPIO/Novita 和已知 Agent ID。 - -首页设置随机 HttpOnly、SameSite=Strict Cookie,Path 限定 `/api`。所有 POST 必须同时满足: - -1. Cookie 与当前 Server token 一致。 -2. `Origin` 为当前端口的 `127.0.0.1` 或 `localhost`。 -3. JSON body 不超过 64 KiB,字段类型严格匹配。 - -静态服务: - -- 路径先 URL decode、规范化并验证仍位于 `frontend/dist`。 -- 拒绝 `..`、NUL、目录列表和未知 MIME。 -- `index.html` 为 `no-store`;Vite hash asset 为一年 immutable cache。 -- CSP 只允许同源脚本、样式、字体和连接;无 CDN、远程字体或内联脚本。 -- 构建缺失时返回本地 503 提示,不从互联网下载资源。 - -## 密钥与文件系统 - -- Key 不进入 URL、命令行、日志、profile、Reducer、localStorage/sessionStorage、截图文件名或遥测。 -- React 完成成功激活后清空 secret ref;日志显示前再次执行当前 Key 精确替换。 -- Unix 私有目录 `0700`,密钥目标、临时文件和备份 `0600`。 -- Windows 对目录、临时文件、目标和 secret backup 重置 ACL、关闭继承,仅授予当前用户和 SYSTEM。 -- ACL 或 mode 设置失败时,临时密钥在发布前删除;无法清理会返回 `CONFIG_WRITE_FAILED`。 -- `profile.json` 只保存 schema、Provider、Base URL、模型、配置模式、Agent ID 和激活时间。 - -## Provider 与 Agent 协议 - -- OpenCode、Kilo CLI、Aider 使用 OpenAI-compatible base。 -- Claude Code 对 PPIO/Novita 使用各自 `/anthropic` base;Custom 显式覆盖由用户负责协议兼容。 -- Codex `wire_api` 使用 Responses。PPIO/Novita 的 Responses 支持必须由 RC 真实 Key 测试确认,不能由 Chat Completions 探测替代。 -- 单一模型 ID 跨 OpenAI、Anthropic、Responses 的兼容性不是既定事实,发布验收必须启动真实 Agent 做首次请求。 - -## 自动测试现状 - -本机当前基线: - -- Python:63 项测试,整体分支覆盖 93%;`installer.py` 为 100% 分支覆盖,Windows 真实 ACL 测试在非 Windows 本机跳过。 -- React:14 项单元测试;状态/API 层分支覆盖 85%。 -- Playwright:6 项 E2E。 -- 完整七页:三个 viewport。 -- existing-account 跳过路径:通过。 -- 部分失败与单 Agent 重试:通过。 -- 浏览器存储保持空,sentinel Key 在激活后从 DOM 消失。 -- Bash CLI 和 GUI smoke:纳入最终回归。 - -Vitest 只收集 `src/**/*.test.{ts,tsx}`;`frontend/e2e` 只由 Playwright 执行,避免测试运行器交叉加载。 - -## CI 与发行 - -普通 CI 矩阵: - -- `macos-15`:macOS arm64。 -- `macos-15-intel`:macOS x64。 -- `windows-2022`:Windows x64。 -- `ubuntu-22.04`:Linux x64。 - -每个平台执行 Python contract、React coverage、生产构建和 Chromium E2E。Windows 额外执行 PowerShell wrapper 和真实 ACL 测试;Unix 执行 Bash/GUI 兼容测试。 - -技术预览工作流在目标系统构建 PyInstaller onedir,运行冻结 CLI smoke,并校验: - -- 无 source map、`node_modules`、`output/`、coverage、Playwright 产物或 Agent 二进制。 -- 无可识别 Key 或 Authorization secret。 -- 包内有 lock manifest 和第三方许可证清单。 -- 外部 release manifest、SHA-256 和 artifact size 与真实文件一致。 - -## 剩余 Release Candidate 门禁 - -以下项目不能在当前 macOS arm64 本机模拟为完成。按 [ADR-005](decisions/ADR-005-channel-neutral-distribution-and-compliance.md),四平台不再要求同时齐备——每个实际发布的平台须满足适用于它的条目: - -1. 每个实际发布的平台在对应操作系统原生生成并启动 onedir 产物。 -2. 五个 Agent 在已发布平台从官方源安装锁定版本,并执行真实 `--version`(`release-candidate.yml` 已定义,CI Secret 未配置,尚未运行)。 -3. Windows 使用空格路径、Unicode 用户名和真实 ACL 完成配置写入(仅在发布 Windows 产物时要求)。 -4. PPIO、Novita 使用受保护低权限 Key 调用 `/v1/models`、最小 Chat Completions、Anthropic Messages 和 Codex Responses(尚未运行,见 README“仍未取得证据的部分”)。 -5. 用生成配置启动五个真实 Agent,验证所选模型完成首次最小请求。 -6. 发行 ZIP、manifest、SHA-256、许可证和 secret scan 全部通过。 -7. 提升到 Stable 时额外完成 macOS 公证和 Windows Authenticode(`scripts/build_release.py` 产物级强制;当前阶段不走 Stable)。 - -如果 PPIO 或 Novita 不支持 Codex 当前要求的 Responses 协议,必须在发布前把该 Agent/Provider 组合降级为明确的不支持或 guide-only;不得通过未批准的本地协议网关掩盖问题。 - -开发期间允许使用 `apiproxy` 档案和 `openai/gpt-5.6-terra` 做 OpenAI、Anthropic、Responses 三协议的本地脚本预检,但它只验证测试工具链,不计入 PPIO/Novita 正式 RC 结果。`openai/gpt-5.6-luna` 只支持两类协议,不用于三协议统一预检。 - -## 开发命令 +## 本地门禁 ```bash cd frontend npm ci npm run test:coverage npm run build -npm run e2e +npm run test:e2e +cd .. +go run ./cmd/oneagent-release build --channel technical-preview-unsigned --skip-frontend +go run ./cmd/oneagent-release check release ``` -```bash -python3.12 -m coverage run --branch -m unittest \ - tests.test_core tests.test_cli tests.test_server \ - tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts -python3.12 -m coverage report --fail-under=85 -bash tests/install_test.sh -python3.12 tests/gui_smoke_test.py -``` - -```bash -python3.12 scripts/build_release.py --channel technical-preview-unsigned --source -python3.12 scripts/check_release.py release -``` - -## 完成定义 - -React 实现本身已经达到功能完成条件:七页、路由守卫、跳过配置、模型回退、部分失败、单 Agent 重试、环境摘要、三 viewport 和 secret ref 均已落地。 - -产品发行仍受门禁约束:每个实际发布平台须原生构建并有 cleanroom 验收证据,真实 Agent 与真实 Provider 协议验收须按上文 RC 门禁执行。从 `technical-preview-unsigned` 提升到 Stable 还须满足平台签名条件(macOS 公证、Windows Authenticode)。四平台不要求同时齐备([ADR-005](decisions/ADR-005-channel-neutral-distribution-and-compliance.md))。 +Wails Alpha 阶段只允许 `technical-preview-unsigned`。真实 Agent/Provider 验收由 `cmd/oneagent-rc`、`cmd/oneagent-provider-smoke` 和对应 cleanroom 负责。 diff --git a/docs/frontend-management-console-plan.md b/docs/frontend-management-console-plan.md index 5a400e58..0cca2020 100644 --- a/docs/frontend-management-console-plan.md +++ b/docs/frontend-management-console-plan.md @@ -1,182 +1,24 @@ -# 前端管理控制台改造计划 +# 前端管理控制台改造计划(已实施) -把「跑完一次向导就结束」的界面改成能长期使用的管理控制台。 +> 状态:已实施(2026-07-31)。React 管理页面通过 Wails bindings 使用 Go profile/config/status 用例。 -**已完成**:首屏减负(`a55f597`)。总览首屏非内容占用从 55% 降到 23%,Agent 列表起始位置从 y=470 提到 197,五个 Agent 完整落在首屏。 +## 已交付 -**本计划涵盖余下四项**:Agent 品牌图标、侧边栏可用性与两个新页面、配置独立成页、视觉规范收敛。 +- 首屏环境总览、Agent 行式管理、Provider 和 Profile 页面。 +- Agent 详情页支持单 Agent 激活、Claude fast model 和配置发现警告。 +- Key 不进入 reducer、浏览器存储或 binding 公开摘要;仅在用户主动打开 Provider 编辑/配置表单时通过本机 binding 读入密码字段。 +- 生成的 `frontend/bindings` 是后端 DTO 的唯一类型来源。 +- Wails server/e2e fake runner 覆盖导航、重试、错误 cause 和临时 HOME;生产构建不使用 server tag。 -范围限于 `frontend/`,不改 Python 内核,唯一的后端改动是修一处已发现的契约泄漏(见 0.4)。 +## 当前验证 -## 0. 先确认的四个事实 +```bash +cd frontend +npm run test:coverage +npm run build +npm run test:e2e +cd .. +go test ./internal/app ./internal/profile ./internal/binding +``` -**0.1 官方图标覆盖不全。** simple-icons(CC0)有 `claude`、`anthropic`、`opencode`、`cursor`,但 **Codex(openai)、Aider、Kilo 均为 404**。CC Switch 也只自带 `chatgpt.svg` 与 `claude.svg` 两个文件——它只管两个 Agent,我们有五个。因此采用**官方 + 自绘补齐**的混合方案(已确认)。 - -**0.2 禁止 CDN。** `CLAUDE.md:72` 规定前端产物不得含 CDN 或远程字体引用,`cdn.simpleicons.org` 不可引用,图标必须内联进 bundle。也不装 `simple-icons` 依赖——为两三个图标引入整包不合理,只取所需 path 内联并注明来源与 CC0。 - -**0.3 商标与 CC0 是两件事。** CC0 只覆盖 SVG 文件本身,不覆盖商标权。在自己 UI 内标示「这一行是哪个 Agent」属指示性使用;不得将第三方 logo 用作 OneAgent 的产品视觉资产、应用图标或营销素材。统一以 `currentColor` 单色渲染而非品牌色,既统一风格,也回避商标着色问题——这正是 macOS 的处理方式:形状各异,容器与尺寸一致。 - -**0.4 已发现一处契约泄漏(需修)。** `installer.py:1348` 把 `PROVIDERS` 常量整体写入 `status_payload`,导致上一轮新增的内部字段 `fallback_probe_model` 泄漏到 `/api/status`,而 `types/api.ts:78` 的类型定义并不包含它。这是探测模型改造引入的漂移。修法:`status_payload` 只投影 `name`/`home`/`base_url`/`anthropic_base_url` 四个公开字段,并补一个测试断言响应中不含 `fallback_probe_model`。 - -**0.5 没有深色模式。** 三个样式文件里 `prefers-color-scheme` 出现 0 次。本次**不新增**深色模式——那是独立的一项工作,混进来会让每条视觉调整都要验两套配色。计划中所有颜色沿用现有亮色 token。 - -## 1. Agent 图标 - -### 实现 - -新增 `frontend/src/components/icons/agents.tsx`,按 Agent ID 映射到图标组件: - -| Agent | 来源 | -| --- | --- | -| Claude Code | simple-icons `claude`(官方,CC0) | -| OpenCode | simple-icons `opencode`(官方,CC0) | -| Cursor 及其他 guide-only | simple-icons 对应条目,有则用 | -| Codex | 自绘 | -| Aider | 自绘 | -| Kilo CLI | 自绘 | - -未知 ID 回退到 lucide `Bot`,保证新增 Agent 不会渲染空白。 - -### 统一规范 - -- 视图框统一 `24×24`;渲染 `18`(列表)/`20`(详情页)。 -- 单色 `currentColor`。官方图标多为实心填充,自绘为线条,两者视觉重量对齐的手段是:实心图标降到 `0.85` 不透明度,线条图标笔画宽 `1.8`(与 lucide 一致)。 -- 复用现有 `.agent-icon` 容器(38×38 圆角方块)。 - -### 文字与可访问性 - -图标**替换**当前按 group 区分的通用图标(现在五个 auto Agent 共用一个 `Code2`,毫无辨识度)。Agent 名称**保留**为主标题——删掉名称会牺牲可访问性与可搜索性。图标 `aria-hidden`,容器加 `title` 提供悬停说明,内容是该 Agent 的一句话定位,不重复名称。 - -## 2. 侧边栏与两个新页面 - -### Bug 根因 - -`NavigationSidebar` 把 `/setup/provider` 和 `/setup/review` 当作独立页面,但它们是受 `SetupGuard` 保护的**向导步骤**:未选 Agent 时守卫重定向回 `/setup/agents`,所以点击像是没反应。这不是样式问题,是把流程步骤误当成了导航目的地。 - -### 新的导航结构 - -| 项 | 路由 | 内容 | -| --- | --- | --- | -| 环境总览 | `/overview` | 已有,Agent 列表 | -| Provider | `/providers` | **新增** | -| 配置模板 | `/profiles` | **新增** | - -向导(`/setup/*`)从侧边栏移除,仅由「激活环境」主按钮与首次运行进入。原「Agent」项指向总览,与「环境总览」重复,删除。 - -### `/providers` - -每个内置 Provider 一行:名称、OpenAI-compatible base URL、Anthropic-compatible base URL(若有)、**正在使用它的 Agent 列表**(由各 Agent 绑定反查,这是 per-agent 之后才有意义的信息)、注册页链接。 - -底部一个自定义 Provider 说明块,指出 Custom 由用户自行保证协议兼容——这条约束已在 ADR-003,界面应当复述。 - -数据全部来自 `status.providers` 与 `status.agents[*].provider`,无需新 API。 - -### `/profiles` - -`profiles/` 模板的可视化管理:模板名、Provider、模型、`hasKey` 标记、创建时间、适用 Agent。操作为「应用到某个 Agent」和「删除」。 - -**安全约束**:模板可携带 Key,页面只能显示 `hasKey` 布尔,**绝不回显 Key 本身**。应用模板时 Key 由后端从 `secrets/` 读取(`--profile` 已有此能力),前端不接触。 - -空态给「从现有 Agent 配置创建模板」的入口——比让用户从零填表更符合真实使用顺序。 - -删除模板需要确认,且要说明删除不会改变已按该模板配置好的 Agent(模板只是模板)。 - -## 3. 配置独立成页 - -现在点「改配置」在卡片内展开面板,导致列表被撑长、多个面板可同时展开、状态散落在各行。 - -### 路由与拆分 - -新增 `/agents/:agentId`。总览的「改配置」改为跳转;整行可点击进入。 - -`AgentManageRow` 退化为**纯展示行**:图标、名称、当前指向、版本、状态徽章。移除内联面板、probe 状态与 apply 逻辑。 - -### 详情页 - -- **头部**:图标、名称、当前 Provider 与模型、版本状态、返回总览。 -- **配置区**:Provider 分段、Base URL(custom 时)、模型、API Key、测试连接、应用。 -- **信息区**(详情页才有空间放的东西):配置文件路径、凭据文件路径、是否有备份、最后更新时间。这些目前无处可去。 -- **应用结果**:重启指引与启动命令。 - -### 必须随代码一起搬走的四条行为约束 - -它们都有测试保护,平移时让测试跟着搬,不要删掉重写: - -1. 探测通过才能应用(防止错误 Key 写入配置)。 -2. 改动 Key 作废上次探测结论。 -3. 应用后显示重启指引(Agent 启动时读配置,不说等于让用户以为失败)。 -4. 应用后清空 Key 输入框(需 remount `SecureKeyField`,它按设计从内部 state 回显)。 - -### 收益 - -列表长度恒定;一次只有一个配置上下文;详情页容得下版本、备份、路径等信息。 - -## 4. 视觉规范收敛 - -现有 `tokens.css` 已是 macOS 风格(`#1d1d1f` 文字、`#007aff` 强调、`rgba(60,60,67,*)` 分隔线),继续沿用,不引入新色板。 - -### 字号收敛 - -实测现有 8 级:11px×14、12px×13、13px×9、14px×4、15px×2、16px、23px、26px。收敛到四级: - -| 级别 | 用途 | -| --- | --- | -| 11px | 辅助信息、路径、时间戳 | -| 13px | 正文、列表项标题 | -| 16px | 分区标题 | -| 23px | 页面标题 | - -12px 合并进 13px,14/15px 合并进 13 或 16,26px 归并到 23px。 - -### 层级只用一种手段 - -现在 `.agent-manage-row` 同时用了边框、白底、阴影三重区分(`app.css:937`)。改为**只靠留白与分隔线**:卡片去掉边框与阴影,行间用 1px 分隔线,容器统一白底。悬停时才给极轻的底色变化。 - -### 其余规则 - -- **一屏一个主操作**:详情页只有「应用」是 primary,其余 secondary 或纯文字按钮。 -- **状态色只用于状态本身**,不作装饰;成功态用完即隐,不常驻(这正是首屏减负的原则,推广到全局)。 -- **动效**只保留 ≤150ms 的 opacity 与 transform 过渡,不做位移动画。 -- **触感一致**:所有可点区域最小高度 28px,圆角统一走 `--radius-control` / `--radius-panel`。 - -## 5. 测试先行 - -按既有约定,每步先写测试。 - -**单元(vitest)** -- `icons/agents.test.tsx`:五个 auto Agent 均有映射;未知 ID 回退;图标 `aria-hidden`。 -- `AgentManageRow.test.tsx`:改造为展示行——断言不再渲染表单控件;点击触发导航。 -- `AgentDetailPage.test.tsx`:平移第 3 节那四条约束。 -- `ProvidersPage.test.tsx`:某 Provider 下正确列出使用它的 Agent;无 Agent 使用时的表述。 -- `ProfilesPage.test.tsx`:列表、空态、**Key 不出现在 DOM 与浏览器存储**、删除需确认。 - -**契约(Python)** -- `test_server.py`:断言 `/api/status` 的 providers 不含 `fallback_probe_model`(0.4 的回归防护)。 - -**e2e(Playwright)** -- 侧边栏三项逐一点击,URL 与标题正确——直接覆盖第 2 节的 bug。 -- 总览进详情,改配置后返回总览看到新指向。 -- 列表长度不因操作而改变(第 3 节的动机)。 -- 三视口无横向溢出(沿用 `expectNoHorizontalOverflow`)。 -- 首屏预算测试已存在,保持通过。 - -覆盖率门禁不变:`src/api`、`src/state` ≥85%;Python 整体 ≥85%,`installer.py` 100% 分支。 - -## 6. 实施顺序 - -| 步 | 内容 | 截图确认 | -| --- | --- | --- | -| 1 | 契约泄漏修复(0.4)+ 图标集 | 是 | -| 2 | 侧边栏修复 + `/providers` + `/profiles` | 是 | -| 3 | `/agents/:agentId` 详情页,`AgentManageRow` 瘦身 | 是 | -| 4 | 视觉规范收敛(字号、层级、动效) | 是 | - -每步独立提交,跑该步相关测试加 `npm run build`;每步结束截图确认后再推进下一步。第 1 步的两项虽都不大,但都是独立可验证的前置工作,合并为一次提交。 - -## 7. 风险 - -- **第 3 步动的是刚落地的 `AgentManageRow`**,四条行为约束必须随测试一起搬移,这是本计划最容易丢东西的地方。 -- **第 2 步会影响既有 e2e**:`wizard.spec.ts` 有从侧边栏进入向导步骤的路径,需同步更新。上一轮移除横幅时已因类似原因让 4 个 e2e 变红,改断言的表达而非删除断言。 -- **第 4 步是大面积样式改动**,回归靠三视口无溢出测试与截图,不靠肉眼扫一遍。 -- **`/profiles` 涉及 Key**,见第 2 节安全约束。 -- 新增图标逐个确认许可,见 0.3。 +新增页面或字段必须先更新 Go DTO 和 binding,再更新 React adapter、state 和测试。旧 HTTP、Cookie/Origin 和解释器契约不再扩展。 diff --git a/docs/mvp-agent-installer-plan.md b/docs/mvp-agent-installer-plan.md index 4bd8693f..b5debd74 100644 --- a/docs/mvp-agent-installer-plan.md +++ b/docs/mvp-agent-installer-plan.md @@ -1,207 +1,12 @@ -# 多页绿色启动器 MVP 计划 +# MVP Agent Installer 计划(已废弃) -## 目标 +> 状态:**Superseded**(2026-07-31)。本文件记录最初的本地 HTTP/Python 原型,不能作为当前操作指南。当前入口请看 [README](../README.md) 和 [Wails 迁移收尾计划](wails-v3-migration-plan.md)。 -做出最小可用的绿色启动器:不重新分发 Agent 包体,用一个本地浏览器多页向导引导用户检测、安装和配置常用 Agent。 +早期原型使用标准库 HTTP GUI、单 Agent CLI 和脚本式配置写入。该原型已由以下实现替代: -MVP 的成功标准是:新用户打开本地 GUI 后,可以选择一个或多个 Agent,决定是否配置模型服务,完成 API Key 与模型 ID 初始化,并看到下一步启动命令。 +- Wails desktop:`cmd/oneagent-desktop` +- headless CLI:`cmd/oneagent` +- Go services/use cases:`internal/app` +- React bindings:`frontend/bindings` -当前实现入口: - -- GUI 启动器:[scripts/gui.py](/Users/ppio/Documents/OneAgent/scripts/gui.py) -- CLI 安装内核:[scripts/install.sh](/Users/ppio/Documents/OneAgent/scripts/install.sh) -- CLI 测试:[tests/install_test.sh](/Users/ppio/Documents/OneAgent/tests/install_test.sh) -- GUI 冒烟测试:[tests/gui_smoke_test.py](/Users/ppio/Documents/OneAgent/tests/gui_smoke_test.py) -- 使用说明:[README.md](/Users/ppio/Documents/OneAgent/README.md) - -## 产品边界 - -- 这是绿色启动器,不是 Agent 包体合集。 -- 不打包、不修改、不重新分发 Claude Code 或 Codex。 -- 不打包、不修改、不重新分发任何上游 Agent。 -- 缺少 Agent 时默认只提示官方安装命令。 -- 用户显式选择后,才对 allowlist 内 CLI Agent 调用包管理器安装。 -- 模型服务配置可跳过,跳过时不要求 API Key,也不写 `~/.oneagent/env`。 -- 默认推荐 PPIO 和 Novita,同时保留 Custom OpenAI-compatible base URL。 -- API Key 只通过本地页面输入或环境变量传给子进程,不放进 GUI 日志和命令行参数。 -- OpenClaw、Hermes、Cursor、Kiro、Gemini CLI 和 IDE 扩展类 Agent 第一版只做官方引导,不写私有配置。 - -## MVP 流程 - -1. 用户运行 `python3 scripts/gui.py`。 -2. 本地服务只监听 `127.0.0.1`,并打开浏览器页面。 -3. 用户从分组目录里多选要处理的 Agent。 -4. 页面展示每个 Agent 的本机安装状态。 -5. 用户选择配置模型服务,或跳过配置并使用官方账号/已有本地配置。 -6. 如果跳过配置,直接进入确认页,只做 Agent 检测或可选官方安装。 -7. 如果配置模型服务,用户选择 PPIO、Novita 或 Custom。 -8. 用户填写 API Key;没有 Key 时可打开对应官网注册或获取 Key。 -9. 页面用当前 base URL + API Key 请求 `GET /v1/models`。 -10. 模型列表成功时默认选择第一个模型 ID;失败时允许手动输入,默认值为 `gpt-4.1`。 -11. 确认页展示 Agent 列表、Provider、base URL、模型 ID、写入路径和备份策略。 -12. GUI 按 Agent 循环调用 CLI 安装内核。 -13. 完成页展示每个 Agent 的结果和下一步命令。 - -## 当前实现 - -### GUI - -- `scripts/gui.py` 使用 Python 标准库 HTTP server。 -- 页面是内嵌 HTML/CSS/JS,不引入 React、Vite、Electron 或 Tauri。 -- API 包括: - - `GET /api/status` - - `POST /api/probe` - - `POST /api/models` - - `POST /api/install` - - `POST /api/open-register` -- 多选 Agent 时,Python 层循环调用 `scripts/install.sh`,CLI 仍保持单 Agent。 -- `AGENT_CATALOG` 是 GUI 和 API 共用的 Agent 元数据源。 -- Guide-only Agent 不调用安装内核,只返回官方安装/配置指引。 - -### CLI - -- `scripts/install.sh` 支持 `--agent codex|claude-code|opencode|kilo-cli|aider`。 -- Provider 支持 `ppio|novita|custom`。 -- `--install-agent` 才会调用 allowlist 内包管理器安装源。 -- `--check-agent-only` 只检测或安装 Agent,不写模型配置。 -- 写配置前会备份旧文件。 - -### Agent 分类 - -可一键配置: - -- Codex -- Claude Code -- OpenCode -- Kilo CLI -- Aider - -只做引导: - -- OpenClaw、Hermes -- Cursor、Kiro、Gemini CLI -- Cline、Continue、Qwen Code、Kilo VS Code - -### Provider 默认值 - -PPIO: - -- 官网:`https://ppio.com/` -- Base URL:`https://api.ppio.com/openai` -- Chat 请求:`https://api.ppio.com/openai/v1/chat/completions` - -Novita: - -- 官网:`https://novita.ai/` -- Base URL:`https://api.novita.ai/openai` -- Chat 请求:`https://api.novita.ai/openai/v1/chat/completions` - -## 配置写入策略 - -### Codex - -写入: - -- `~/.codex/config.toml` -- `~/.oneagent/env` - -完成后提示: - -```bash -source ~/.oneagent/env && codex -``` - -### Claude Code - -写入: - -- `~/.claude/settings.json` - -字段: - -- `ANTHROPIC_BASE_URL` -- `ANTHROPIC_AUTH_TOKEN` -- `ANTHROPIC_MODEL` - -完成后提示: - -```bash -claude -``` - -### OpenCode / Kilo CLI - -写入: - -- `~/.config/opencode/opencode.jsonc` -- `~/.config/kilo/kilo.jsonc` -- `~/.oneagent/env` - -配置使用 `@ai-sdk/openai-compatible`,Base URL 写成 `/v1`。 - -### Aider - -写入: - -- `~/.oneagent/aider.env` - -完成后提示: - -```bash -source ~/.oneagent/aider.env && aider --model openai/ -``` - -## 验收标准 - -- [ ] 首屏展示 Agent 分组目录和安装状态。 -- [ ] 可以跨分类多选 Agent。 -- [ ] 可一键配置 Agent 能写入本地配置。 -- [ ] Guide-only Agent 不写本地配置,只返回指引。 -- [ ] 可以跳过模型服务配置,并且不要求 API Key。 -- [ ] 跳过配置时不写 `~/.oneagent/env`。 -- [ ] PPIO 和 Novita 显示正确官网和 base URL。 -- [ ] Custom 允许填写自定义 base URL。 -- [ ] 可以通过 `GET /v1/models` 获取模型 ID。 -- [ ] 模型列表失败时可以手动输入模型 ID。 -- [ ] API Key 不出现在 GUI 日志或子进程命令行参数中。 -- [ ] CLI 仍可独立运行。 -- [ ] README 不推荐日常使用明文 `--api-key` 参数。 - -## 验证方式 - -自动测试: - -```bash -./tests/install_test.sh -python3 tests/gui_smoke_test.py -bash -n scripts/install.sh tests/install_test.sh -python3 -m py_compile scripts/gui.py tests/gui_smoke_test.py -``` - -手动验收: - -1. 运行 `python3 scripts/gui.py`。 -2. 选择 Codex + OpenCode + OpenClaw。 -3. 选择跳过配置,确认可以直接进入最终确认页。 -4. 选择 PPIO 或 Novita,确认无 Key 时能打开官网。 -5. 使用 mock key 测试连接,确认 401/403 被解释为端点可达但 Key 被拒绝。 -6. 模型列表失败时,确认仍可手动输入模型 ID 并继续。 - -## 暂不做 - -- Windows 原生安装器。 -- Electron / Tauri 桌面包。 -- 重新分发 Agent 包体。 -- 为每个 Agent 单独配置不同 Provider 或模型。 -- 企业 SSO。 -- 团队管理和账单。 -- Agent 自动更新。 -- 复杂模型路由界面。 - -## 后续方向 - -1. 将注册页参数和官网归因数据打通。 -2. 增加更完整的本地诊断报告。 -3. 支持设备码登录,减少手动粘贴 Key。 -4. 在有稳定激活数据后,再考虑桌面安装包。 -5. 多个 Agent 跑通后,再考虑 Agent Hub。 +历史方案中的 localhost HTTP、Cookie/Origin、解释器启动器和旧 GUI 文件均已删除。保留本文件只是为了说明早期产品边界;新增功能不得依赖其中的路径、端口或命令。 diff --git a/docs/per-agent-config-plan.md b/docs/per-agent-config-plan.md index ab666db8..777cd64b 100644 --- a/docs/per-agent-config-plan.md +++ b/docs/per-agent-config-plan.md @@ -1,106 +1,28 @@ -# 选择页排序修复,与详情页分化的评估 +# Per-Agent 配置计划(已实施) -起因是两个诉求:激活环境页没把重要 Agent 排在前面;每个 Agent 的配置界面完全一致。参考对象是 CC Switch。 +> 当前实现位于 `internal/config` 和 `internal/app`。本文保留设计结论,旧脚本路径不再适用。 -结论先行:**只做选择页排序修复。** 详情页按适配器分化经核对后不成立——它要暴露的多数是内部实现差异,而隐藏这些差异正是 OneAgent 的价值。唯一值得单独评估的是 Claude Code 的快速小模型字段。 +## 适配器 -调研版本 `cc-switch` 3.18.0(commit `708b387`),MIT,克隆在被忽略的 `output/reference/cc-switch`。背景见 [CC Switch 参考笔记](cc-switch-reference-notes.md)。 +| Agent | 配置适配器 | 凭据交付 | +| --- | --- | --- | +| Codex | TOML provider + 专属 env | `oneagent_env` | +| Claude Code | settings JSON + native env | `native_env` | +| OpenCode/Kilo CLI | OpenAI-compatible JSON + 专属 env | `oneagent_env` | +| Aider | env 脚本 | `config_file` | -## 1. CC Switch 的表单结构 +适配器由 `agents.lock.json.config_adapter` 选择;新增 Agent 不复制版本、命令或路径常量。配置写入会保留用户未管理字段、创建安全备份并原子替换。 -它的形态是三层,不是「每个 Agent 一套独立表单」: +## Claude Code fast model -``` -共享 ProviderForm 编排器(2693 行) - + 共享字段组件与 hooks - hooks/ 24 个文件 4733 行(useOpencodeFormState、useHermesFormState、useCodexConfigState…) - shared/ 5 个文件 410 行(ModelInputWithFetch、EndpointField、ApiKeySection、ModelDropdown) - helpers/ 166 行 - BasicFormFields 177 · ApiKeyInput 69 · ProviderAdvancedConfig 182 - + 各应用专属 FormFields 区块 - OpenCode 1148 · Codex 1107 · Claude 1127 · OpenClaw 673 · Hermes 569 · Gemini 213 -``` - -共享层本身接近 6000 行,专属区块坐在它上面。这与「共用骨架 + 按需分区块」是同一种形态。 - -几点需要说准: - -- **`OmoFormFields`(1321 行)不是同级 Agent。** `types.ts:8-9` 写的是 `"omo" // Oh My OpenCode` 与 `"omo-slim"`,属 OpenCode 下的配置类别。 -- **Gemini 表单不只有 OAuth。** 官方 Google 路径走 OAuth,自定义或第三方 Gemini Provider 仍可填 Key、Endpoint、模型并拉取模型列表。213 行是因为它复用共享字段,不是因为没什么可填。 -- **Codex 的上游格式三选不是 `wire_api` 三选。** 源码明确标注 Codex 的 `wire_api` 固定为 `responses`;三选属于它的代理转换层——而本地代理在 [产品边界基线](product-boundary-baseline.md) 里是明确禁止范围。 -- **它并非「用户必须自己懂原始格式」。** `settingsConfig` 是原生 JSON 对象存储,但 UI 与 Service 层仍会理解、校验和编辑字段语义。参考笔记第 2 节的表述过头了。 - -**正确的借鉴原则**:只有当用户面对一个真实的、可选择的语义需求时才分化 UI。**底层配置文件不同本身不构成分化理由。** - -## 2. 我们的适配器差异,哪些是用户的选择 - -`_write_agent_config`(`oneagent/installer.py:399`)分派四个适配器,写的东西确实不同: - -| Agent | 目标 | 密钥去处 | 模型 | -| --- | --- | --- | --- | -| Codex | `config.toml` 的 `[model_providers.oneagent]` | env 文件,经 `env_key` 间接引用 | `model` 单值 | -| Claude Code | `settings.json` 的 `env` | 写进配置文件(`ANTHROPIC_AUTH_TOKEN`) | `ANTHROPIC_MODEL` + `ANTHROPIC_SMALL_FAST_MODEL` | -| OpenCode / Kilo | `provider.oneagent` + `models` 映射 | env 文件,`{env:...}` 占位 | `oneagent/` | -| Aider | shell / PowerShell 脚本 | 写进脚本(`secret=True`) | 不写,靠启动参数 | - -按第 1 节的原则筛一遍,**只有一项是用户的选择**: - -- **Claude Code 的 `ANTHROPIC_SMALL_FAST_MODEL`**(`installer.py:384-385`)现在被强制等于主模型。「主模型 + 快速小模型分别指定」是一个真实能力,用户可能确实想让小模型更便宜。这是唯一值得建 UI 的差异。 - -其余三项都只是内部实现,不该出现在界面上: - -- 密钥最终写到配置文件还是 env 文件; -- 模型放在配置文件还是启动参数; -- 用哪种 env 占位格式(`env_key` / `{env:...}` / `export`)。 - -用户对这些没有选择权,讲出来只是增加认知负担。**隐藏它们正是产品价值。** - -## 3. 先前判断中已修正的错误 - -记下来避免重复踩: +`small_fast_model` 是可选字段。为空时回退主模型;有值时同时写入 settings 和 native env。前端 Advanced section、Go binding 和 CLI 使用同一字段。 -- **「Aider 留空会使启动命令缺模型参数」错。** `install_many` 与 `activate_agent` 都在写任何东西之前无条件补齐模型(discovery,失败则 `fallback_probe_model`)。后端拿到的 `model` 永远非空,「留空则由端点自动选择」对 Aider 同样成立。误推的环节是漏掉了 resolve 这一步。 -- **「只有 Codex/OpenCode/Kilo 需要 source」错。** `_next_step` 里 Aider 也要 `source ~/.oneagent/aider.env && aider --model openai/`。**只有 Claude Code 是裸 `claude`。** -- **「详情页对所有 Agent 给出相同启动说明」错。** 输入表单是共用的,但 `restart` 与 `next` 由后端按 Agent 生成,详情页直接显示 `applied.restart` / `applied.next`,结果区本就是分化的。 -- **「rank 完全没用上」错。** `catalog.py` 已按 rank 排序输出,选择页拿到的就是排好的数组。准确的说法是 rank 没有参与「首屏还是折叠」的决定。 -- **「guide-only 必须不可勾选」错,且会破坏现有流程。** `installer.py:1079-1082` 对 `config_mode == "guide"` 返回 `status: "guide-only"`,把 `meta["guide"]` 放入 `next_steps`,不装包、不写私有配置;Review 页显示「显示引导」。**合规要求是「不自动配置」,不是「不允许选择」。** +## 验收 -## 4. 要做的:选择页排序修复 - -`AgentSelectionPage.tsx:13` 以 `group === "auto"` 决定首屏,`:14-20` 把其余按 `group.id` 塞进「更多分类」折叠。结果 Kilo(rank 8)、Aider(9)在首屏,而 Cursor(3)、OpenClaw(5)、Hermes(6)被折叠。总览页 `EnvironmentOverviewPage.tsx:35-37` 已按 rank 排并以 `PRIMARY_RANK_LIMIT = 6` 划线,选择页没跟上。 - -改动: - -- 把 rank 排序与首屏阈值抽到 `frontend/src/state/ranking.ts`,选择页与总览页共用,避免第三次各写一遍。 -- 选择页改用统一排序划分首屏与折叠区,删掉 `automatic` / `additionalGroups` 两个 `useMemo`。 -- 首屏标题从「可一键配置」改为「常用 Agent」——按 rank 混排后首屏含 guide-only,原标题不再成立。折叠区标题改为「更多 Agent」。 -- **guide-only 保持可勾选**。`AgentRow` 的 `disabled` 条件不动(仍只在 `platforms.length === 0` 时禁用)。注意「仅引导」徽标只在该 Agent **未安装** 时出现——`AgentRow` 的状态优先显示「已安装」。本机已装 Cursor 时首屏三个 guide-only 行都显示「已安装」,说明这一点的是行内那句「显示官方安装与配置步骤」,断言应该盯它而不是徽标。 - -测试: - -- `AgentSelectionPage.test.tsx`(新建)——断言渲染顺序为 rank 顺序、Cursor 在 Kilo 之前;断言 guide-only 行的 checkbox 未禁用;mock 的 catalog 故意逆序传入,确保页面不依赖服务端顺序。 -- `ranking.test.ts`(新建)——排序、并列按 id、`undefined` 视作空、阈值取等号侧。 -- `wizard.spec.ts` 已有 rank 齐全的 mock(`:15-20`),补首屏顺序与 guide-only 可勾选断言。 - -改完开 `8765` 用真实 DOM 复查顺序与勾选态。 - -## 5. 暂缓:按 `configAdapter` 拆详情页 - -按第 2 节的筛选结果,Codex / OpenCode / Kilo / Aider 的「专属区块」内容都是内部实现说明,不建。不启动 `configAdapter → 多区块` 体系,也不为此改 `AgentCatalogItem` 契约。 - -## 6. Claude Code 双模型字段 — 已实现(产品已确认) - -落实情况:产品确认支持后按本节落点实施。`write_claude_config` 与 `write_agent_env`、`activate_agent` 增可选 `small_fast_model`(留空回退主模型),activate 端点与 `frontend/src/types/api.ts` 同步;`AgentDetailPage` 的 `AdvancedSection` 内增 Claude Code 专属「快速小模型」字段并经 `api.activateAgent` 传 `small_fast_model`。`installer.py` 两侧用例(给值 / 留空)守住 100% 分支门禁,`AgentDetailPage.test.tsx` 与 `client.test.ts` 覆盖前端两侧。未建立完整分区块体系——仅这一个字段。 - -若产品确认支持「主模型 + 快速小模型」,落点是现有 `AdvancedSection` 里增一个 Claude Code 专属可选字段,留空回退到主模型。涉及 `write_claude_config` 与 `activate_agent` 增一个可选参数、activate 端点与 `types/api.ts` 同步,以及 `installer.py` 100% 分支门禁要求的两侧用例(给值 / 留空)。 - -**仅这一个字段不需要建立完整的分区块体系。** 先确认产品是否要这个能力,再决定是否实施。 - -## 7. 明确不做 - -- CC Switch 的上游格式三选、prompt cache 路由、reasoning 档位、四项分价、任意 headers 与 extra options —— 我们的适配器不写这些,建了 UI 就是空承诺;其中上游格式三选依赖它的代理转换层,属产品边界禁止范围。 -- 每 Agent 独立表单组件 —— 我们四个适配器的共用部分远大于差异部分。 -- guide-only Agent 的配置界面 —— 两个页面上都只提供官方引导入口。 -- 改 `rank` 数值 —— 当前排序是上一轮的决定。 +```bash +go test ./internal/config ./internal/app +bash tests/install_test.sh +go run ./cmd/oneagent-rc adopted +``` -相关文档:[CC Switch 参考笔记](cc-switch-reference-notes.md)、[产品边界基线](product-boundary-baseline.md)、[前端管理控制台改造计划](frontend-management-console-plan.md)。 +配置采用检查使用丢弃端口和假 Key,确保 Agent 读取了配置后才会在网络层失败;它不需要真实 Provider Key。 diff --git a/docs/provider-rc-testing.md b/docs/provider-rc-testing.md index e7485ae0..7219cc8f 100644 --- a/docs/provider-rc-testing.md +++ b/docs/provider-rc-testing.md @@ -4,7 +4,7 @@ OneAgent 将 Provider 测试分为两个层级: -1. 本地临时预检:验证测试脚本、鉴权头和三类协议请求结构可以工作。 +1. 本地预检:复用 Go Provider 客户端,验证鉴权头和三类协议请求结构。 2. 正式 Release Candidate:使用 PPIO、Novita 各自的受保护低权限 Key 验证真实供应商能力。 本地预检不能替代正式 RC,也不能用于宣称 PPIO 或 Novita 已通过兼容性验收。 @@ -14,6 +14,7 @@ OneAgent 将 Provider 测试分为两个层级: 每个正式 Provider 必须分别提供以下变量,即使三个变量暂时使用同一个模型 ID,也不得合并为单一变量: ```text +ONEAGENT__API_KEY ONEAGENT__OPENAI_MODEL ONEAGENT__ANTHROPIC_MODEL ONEAGENT__RESPONSES_MODEL @@ -22,51 +23,33 @@ ONEAGENT__RESPONSES_MODEL 对应请求为: | 槽位 | 请求 | -|---|---| +| --- | --- | | OpenAI | `POST /v1/chat/completions` | | Anthropic | `POST /v1/messages`,包含 `X-Api-Key` 和 `Anthropic-Version` | | Responses | `POST /v1/responses` | `GET /v1/models` 只验证鉴权和模型目录可访问,不能证明三个推理协议均兼容。 -## 临时 apiproxy 档案 +## 本地执行 -截至 2026 年 7 月 22 日,本地低 Token 请求确认 `openai/gpt-5.6-terra` 在以下四个请求上均返回 HTTP 200: - -```text -GET https://apiproxy.paigod.work/v1/models -POST https://apiproxy.paigod.work/v1/chat/completions -POST https://apiproxy.paigod.work/v1/responses -POST https://apiproxy.paigod.work/v1/messages -``` - -临时档案的三个模型槽位当前都设置为: - -```text -openai/gpt-5.6-terra +```bash +go run ./cmd/oneagent-provider-smoke --provider ppio --timeout 30s ``` -`openai/gpt-5.6-luna` 只支持两类协议,因此不能作为三协议预检的统一默认模型。 - -本地执行命令: +命令只从环境变量读取 Key 和模型,不接受命令行凭据,也不会把响应正文写入日志。正式 RC: ```bash -python3 scripts/provider_rc_smoke.py \ - --provider apiproxy \ - --api-key-json ~/.codex/auth.json \ - --api-key-field OPENAI_API_KEY \ - --timeout 45 +go run ./cmd/oneagent-provider-smoke --provider all --timeout 30s ``` -Key 从 JSON 文件内部读取,不作为命令行参数值传递。当前本机认证文件中的实际字段名是 `OPENAI_API_KEY`,不是 `OPENAIKEY`。 +`all` 严格只运行 PPIO 和 Novita。自定义端点应在单独的环境隔离中运行,临时代理结果不能替代正式供应商证据。 ## 正式 RC 门禁 -`.github/workflows/release-candidate.yml` 的 `--provider all` 只运行 `ppio` 和 `novita`。正式 RC 仍要求: +`.github/workflows/release-candidate.yml` 的 Go smoke 仍要求: - `ONEAGENT_PPIO_API_KEY` 和 `ONEAGENT_NOVITA_API_KEY` 存放在受保护 CI Secret。 - 两个 Provider 分别配置 OpenAI、Anthropic、Responses 三个模型变量。 -- 四个端点全部成功,并继续执行真实 Agent 首次请求验收。 -- 任一协议不支持时,明确降级对应 Agent/Provider 组合,不使用代理预检结果绕过发布门禁。 - -临时 `apiproxy` 档案不进入 `--provider all`,也不写入正式 RC workflow 的 Secret 或变量列表。 +- `/v1/models`、Chat Completions、Responses 和 Anthropic Messages 全部成功。 +- 随后的真实 Agent 安装、PATH、锁定版本和无密钥配置采用检查全部通过。 +- 任一协议不支持时,明确阻止对应 Agent/Provider 组合,不使用其他协议或临时端点绕过门禁。 diff --git a/docs/public-site-operations.md b/docs/public-site-operations.md index 3250d5e6..c21a8564 100644 --- a/docs/public-site-operations.md +++ b/docs/public-site-operations.md @@ -1,36 +1,25 @@ -# OneAgent 公开分发站运营与发布手册 +# OneAgent 公开站运营与发布手册 -状态:实施中,适用于 `technical-preview-unsigned` 和未来逐平台 Stable 发布。 +状态:已实施。 -## 1. 固定架构 +## 架构边界 -- `frontend/` 是随 Launcher 打包的本地七页向导,继续服从无 CDN、资源内联和本地 API 安全约束。 -- `site/` 是独立的 Astro 静态站,只提供产品说明、下载、教程、兼容目录、安全政策、支持和企业服务页面。 -- 两者不共享路由、状态或运行时组件;首期只复用品牌语言、真实截图和 Agent 标识资产。 -- 官网不进入 OneAgent 安装包,网站构建失败不能改变 Launcher 的本地运行行为。 +- `frontend/` 是随桌面 App 打包的 React 客户端。 +- `site/` 是独立构建和部署的 Astro 静态站,不进入 App 包体。 +- `.github/workflows/technical-preview.yml` 只构建 Go/Wails App 资产并创建 Draft GitHub Release。 +- `.github/workflows/site.yml` 只测试、构建和部署 GitHub Pages。 -## 2. 唯一发行事实源 +两个工作流没有 artifact 或 job 依赖。发布者人工审核并发布 Draft Release 后,`release.published` 事件会触发站点重建。 -公开下载数据由三层组成: +## 版本事实源 -1. `release/release-manifest--.json`:构建产生的版本、平台、架构、Agent 锁定版本和 artifact 哈希。 -2. `release/SHA256SUMS--.txt`:artifact 与 manifest 的独立校验记录。 -3. `distribution/channels.json`:人工审核的平台公开状态、原生构建/cleanroom 证据和下载渠道。 +公开站在构建时调用 GitHub Releases API,只读取已发布、非 Draft 的 Release。页面上的版本标签、发布日期、下载地址、文件大小和可用的 SHA-256 digest 均来自该 API;没有 Release 时页面明确显示尚未发布。 -`scripts/build_release_index.py` 校验三层一致性并生成 `site/src/generated/release-index.json`。可公开下载的平台必须同时满足: +站点不读取 App 的本地 `release/` 目录,不复制下载资产,也不维护版本回退值。Agent 目录直接读取 `agents.lock.json`,Provider 目录直接读取根目录的 `providers.lock.json`;运行时端点和公开披露字段由同一份清单管理。 -- manifest 和 checksum 文件存在; -- artifact 文件大小与 SHA-256 完全一致; -- `native_build=true`; -- `cleanroom=verified` 且 evidence 非空; -- 有且只有一个 primary 官方下载渠道; -- 外部镜像使用 HTTPS,并声明与 artifact 完全相同的 `verified_sha256`; -- available 渠道有明确的 `published_at`; -- 渠道与签名状态一致,unsigned 构建不能进入 Stable。 +私有仓库构建需要提供具有 `contents:read` 权限的 `GITHUB_TOKEN`。独立 Pages 工作流使用当前任务的 GitHub token;未提供 token 的本地构建若无法读取私有仓库,会渲染“尚无已发布版本”。 -网站的 `/release-index.json` 与下载页读取同一份生成数据,禁止再维护手工版本表。 - -## 3. 本地开发和验收 +## 本地验证 ```bash cd site @@ -41,83 +30,29 @@ npx playwright install chromium npm run test:e2e ``` -`npm run prepare:data` 会从仓库根目录的 manifest、渠道配置、`agents.lock.json` 和 Provider 公开配置重新生成网站数据,并把当前标记为 available 的官方同包 artifact 复制到网站构建目录。 - -`site/src/generated/` 与 `site/public/downloads/` 都是可再生目录,**均不提交到 Git**:`catalog.json` 是 `agents.lock.json` 加 Provider 配置的纯函数,提交它只会让每次构建产生 diff;`release-index.json` 含 artifact 校验和,提交等于把某一台机器的构建结果冻进仓库。干净检出下 `npm run build` 会先跑 `prepare:data` 重新生成,无需额外步骤。 - 模拟 GitHub Pages 子路径部署: ```bash SITE_URL=https://example.com BASE_PATH=/OneAgent npm run build ``` -**这份产物不能用 `astro preview` 在本地查看。** `BaseLayout.astro` 会输出绝对 URL 的 ``,而同一份 CSP 声明了 `base-uri 'self'`:从本机 origin 伺服时浏览器拒绝该 base 标签,样式表与 Agent 标识全部 404,**而每个页面仍然返回 200,只是退化成无样式 HTML**。部署到 Pages 时 base 与页面同源,`'self'` 放行,因此线上不受影响。 - -跑完这条命令后要恢复可预览的产物,直接重新 `npm run build` 即可。`site.spec.ts` 有一条断言同时检查无 4xx、背景色取自本站样式表、图片全部解码成功,正是为了让这种「200 但坏了」的状态在测试里可见而不是靠肉眼发现。 - -## 4. 受控预览发布 +该子路径产物的绝对 `` 只适用于配置的 origin。恢复本地预览时重新运行普通 `npm run build`。 -`.github/workflows/technical-preview.yml` 的顺序固定为: +## 发布顺序 -1. 四个平台原生构建 unsigned preview; -2. 打包 CLI 冒烟和包体检查; -3. macOS arm64 执行真实 cleanroom; -4. 汇总所有平台 manifest 与 checksum; -5. 生成公开 release index、执行网站单元/类型/完整性、三档视口浏览器与可访问性检查; -6. 只把 `distribution/channels.json` 中标记为 available 的平台 artifact、manifest、checksum 和 release index 放进 Draft GitHub prerelease; -7. 人工核对并在 GitHub 上公开该 prerelease; -8. 再次手动运行工作流,关闭 `create_draft_release`、开启 `deploy_pages`;工作流会丢弃本次重建的包体,重新下载已公开 prerelease 的不可变资产来生成下载页,通过门禁后部署 GitHub Pages。 - -手动触发需要指定 preview tag。`deploy_pages` 默认关闭;Tag 触发只创建不可变 Draft,不会自动部署网站。Draft 只在人工复核平台状态、发行说明和下载校验后公开。工作流拒绝覆盖同一 tag 下已有的不同字节;任何包体变化都必须先提升 OneAgent 版本并使用新 tag,不得在同一版本下替换 artifact。 - -四个平台的构建结果仍会作为 CI artifact 保留用于验证,但未标记 available 的 Windows、Linux 或 macOS x64 包不会进入 GitHub Release,也不会被复制到网站下载目录。 +1. 运行 `Technical Preview Packages`,构建并验证各平台 App 资产。 +2. 工作流以新 tag 创建不可变 Draft prerelease;已有 tag 会直接失败,不覆盖资产。 +3. 人工检查资产、校验和、签名状态和发行说明后发布 Release。 +4. `Public Site` 工作流自动从默认分支构建站点,从该 Release 读取版本与下载信息并部署 Pages。 +5. 仅修改站点、Agent 目录或 Provider 披露时,合入 `main` 即可独立部署,不触发 App 构建。 GitHub repository variables: -- `ONEAGENT_PUBLIC_SUPPORT_URL`:公开 Issues、Discussions 或其他可访问的反馈入口;为空时网站明确显示尚未启用。 -- `ONEAGENT_PUBLIC_BUSINESS_EMAIL`:公开商务邮箱;为空时企业页不展示虚构联系方式。 - -域名、DNS、备案、Pages 开关和签名证书属于仓库外部依赖。 - -## 5. 镜像与撤回 - -新增官网外镜像时: - -1. 上传现有官方 artifact,禁止重新压缩、追加文件和二次签名; -2. 下载镜像文件并重新计算 SHA-256; -3. 在 `distribution/channels.json` 中使用 `kind=mirror` 并填写与 manifest 完全相同的 `verified_sha256`; -4. 在镜像的 `audit` 对象中记录 `uploaded_by`、`uploaded_at`、`verified_at`、`withdrawal_owner` 和 `withdrawn`;撤回时补充 `withdrawn_at`。镜像必须使用 HTTPS,且不能成为 primary 官方渠道; -5. 重新构建网站,生成器会拒绝哈希未确认的外部镜像。 - -发生错误或安全事件时,将目标状态改为 `withdrawn`、移除下载链接并重建网站,同时撤回所有渠道。禁止在同一个版本号下替换为不同字节的文件;修复后必须发布新版本。 - -## 6. Provider 合作披露 - -Provider 的公开商业数据保存在 `distribution/providers.json`,与 Agent rank 和运行时兼容逻辑分离。 - -- `relationship=none`:只链接官方主页。 -- `relationship=referral` 或 `sponsor`:必须同时填写 disclosure 和 referral URL。 -- 商业关系不能改变 Agent/Provider 技术结论、默认选择、连接探测或页面排序。 -- OneAgent 不代理推理请求、不托管 Key、不代收充值,不承诺 Provider 的永久价格或固定免费额度。 -- 首期只链接官方价格页,不在仓库内复制容易过期的价格表。 - -## 7. 支持、统计与增长 - -- 文档、校验信息与已知限制保持公开,社群不是下载前置条件。 -- 应用继续默认无遥测;网站当前也不加载客户端分析脚本。 -- 如后续启用无 Cookie 聚合统计,允许事件仅限页面访问、下载点击、快速开始入口和企业联系入口;禁止设备指纹、跨站身份、API Key、本地路径和模型请求内容。 -- 初始漏斗目标:主页到下载页 20%,下载用户进入快速开始 40%,每 100 次下载的重复性安装支持请求少于 20。 - -## 8. 企业服务与产品化门禁 - -首期只销售三个可复用结果:团队启用、环境标准化和商业支持。内部镜像仍只能分发 OneAgent 官方同包产物,不得包含未授权 Agent 二进制。 +- `ONEAGENT_PUBLIC_SUPPORT_URL`:公开支持入口;为空时不展示虚构地址。 +- `ONEAGENT_PUBLIC_BUSINESS_EMAIL`:公开商务邮箱;为空时不展示虚构邮箱。 -在至少获得 3 个设计合作方、其中 2 个付费试点前,不开发专业版许可证系统。若试点反复提出团队模板、合规报告、组织版本策略或内部升级策略,必须新建 ADR,重新评估权限、秘密管理、迁移与隐私边界。 +## Provider 与稳定版边界 -## 9. Stable 门禁 +Provider 商业数据保存在 `providers.lock.json`,不能影响 Agent rank、兼容性结论、默认选择或连接测试。 -- macOS:Developer ID、notarization、stapled ticket、原生 cleanroom。 -- Windows:有效 Authenticode、原生构建、SmartScreen 场景验证。 -- Linux:按实际架构原生构建和 cleanroom。 -- 每个平台独立进入 Stable;网站允许同一时间存在不同成熟度。 -- Stable 初期仍采用手动升级说明,不在本阶段加入自动更新。 +Stable 仍需按平台满足签名、公证和原生 cleanroom 门禁。GitHub Release 是公开版本与资产的事实源,不替代 App 发布流程中的产物验证。 diff --git a/docs/recent-work-summary.md b/docs/recent-work-summary.md index 57172b62..84a752a3 100644 --- a/docs/recent-work-summary.md +++ b/docs/recent-work-summary.md @@ -1,88 +1,32 @@ -# 近期工作纪要 +# Recent Work Summary -分支 `codex/trusted-distribution-site`,12 个提交,97 文件 +13650/−110。已推送 `origin` 与 `maimory`。 +> 更新:2026-07-31。本文记录当前可复核的 Go/Wails 收尾结果;旧的 Python 计数和命令不再是验收依据。 -四条线:公开分发站、安装链路可验证、配置链路能用、入口分流。每条都是先实测、再修、再用测试固定住结论。 +## 已完成 -发现的三个缺陷有个共同点:**它们都表现为成功**——页面返回 200 但整站无样式,Agent 报 `configured` 但启动即失败,`tsc` 干净但返回用户被送去营销页。所以每条都补了能看见「假成功」的断言,而不只是修掉现象。 +- Go backend 覆盖 catalog、Provider、安装、配置发现/写入、profile、secret、备份、权限和 CLI。 +- React 已切换到生成的 Wails bindings;桌面生产路径不使用 HTTP API。 +- 配置写入使用 Go golden fixtures,直接锁定 JSON/TOML 输出,不启动第二套 runtime。 +- `cmd/oneagent-release` 生成原生 Wails/Go 包、源码 ZIP、manifest、SHA-256 和第三方 notices。 +- `cmd/oneagent-rc` 覆盖隔离 npm prefix、真实锁定版本、PATH 解析和无密钥配置采用。 +- `cmd/oneagent-provider-smoke` 覆盖 models、Chat Completions、Responses、Anthropic Messages。 +- Docker/macOS cleanroom、Go race、React/site 构建和 Wails binding diff 均有独立入口。 -## 1. 公开分发站(`bc01bcd` → `67b3706`) +## 当前验证入口 -独立的 Astro 静态站,26 页,无第三方脚本,CSP `default-src 'self'`。 - -**核心决定是下载页的每个断言都必须可核对。** 版本、SHA-256、cleanroom 结论都由 `scripts/build_release_index.py` 从 `release/` 的实际产物生成,缺产物或校验和不符就拒绝出条目。手工维护版本表会让页面宣传一个从未构建出来的包,对未签名预览版而言这是最不能犯的错。 - -`site/src/generated/` 与 `site/public/downloads/` 不入库:前者是 `agents.lock.json` 加 Provider 配置的纯函数,提交只会让每次构建产生 diff;后者含 artifact 校验和,提交等于把某台机器的构建结果冻进仓库。 - -**过程中发现一个真实缺陷。** 站点在本地打开时整站无样式、图标全 404,但**每个页面仍返回 200**。根因是 `BaseLayout.astro` 输出绝对 URL 的 ``,与同文件 CSP 的 `base-uri 'self'` 冲突——浏览器拒绝该标签,资源路径全部落空。线上不受影响(Pages 与页面同源),但任何人手动带 `BASE_PATH` 构建后本地预览就是坏的,而症状是 200 而非报错。`site.spec.ts` 现在同时检查无 4xx、背景色取自本站样式表、图片全部解码,让这种「200 但坏了」的状态在测试里可见。这条断言做过反向验证:故意用 `/OneAgent` base 构建后,它确实失败。 - -顺带修掉一个分发问题:源码归档白名单加 `site/src` 时把 `src/generated/` 一并扫进去了,发布的源码包会携带某台机器的校验和。原有测试只 grep 函数源码文本,看不到这个;新测试断言 `source_files()` 的实际返回值。 - -## 2. 安装链路可验证(`7c30c5d`、`9a20b21`、`f271d88`) - -问题是「Agent 装得上」此前是个假设。手动实测确认装得上(两个锁定版本在 registry 上都存在,落到 PATH,版本相符),然后把结论固化成可重复的脚本。 - -**`integrity` 记而不验。** `agents.lock.json` 为每个 npm 包记了 sha512,也有测试断言它存在,但 `install_locked_agent()` 全函数不读它——**版本锁住了,字节没有。** 现在安装前用 `npm view` 取 registry 声明的 `dist.integrity` 与清单比对。 - -**这个校验正是镜像可被接受的前提。** 国内网络下官方 registry 常不可达,而 [产品边界基线](product-boundary-baseline.md) 第 5 节已把「授权镜像」列为优先级 2,条件是有许可证、版本锁定、校验值和上游地址。换 registry 是换取包的渠道,不是代理用户的网络——不建隧道、不转发流量,这是基线划的界。两个锁定包在 npmmirror 与官方源上的 integrity **逐字节相同**,所以这是同一份包的两个渠道。 - -三个设计决定:默认永远官方源,镜像只能显式选择(自动切换会让用户不知道包从哪来);只允许 HTTPS 且拒绝内嵌凭据(registry URL 会进入安装环境和日志);实际使用的 registry 记入日志。镜像不得指向 OneAgent 运营的存储——重新分发商业 Agent 需要授权,而指向公开只读镜像不需要。 - -验证分三层:`tests/test_install_contract.py`(42 用例,离线,断言 argv 而非执行)进常规 CI;`tests/real_install_test.sh` 真实安装,隔离 npm 前缀排在 PATH 最前,官方源与镜像双路径实测通过;`scripts/agent_e2e_smoke.py` 需真实 Key,手动运行。真实安装不进常规 CI——每次提交打 registry 既慢又会限流。 - -**一处更正**:我曾判断「没有任何一层真的装包」,这是错的。`scripts/verify_locked_agents.py` 一直在真实安装,且覆盖全部五个 Agent。真实缺口是它之后的两步:shell 是否同意二进制可达,以及 Agent 带着配置能否真的应答。 - -## 3. 配置链路能用(`74d6633`、`e3e4782`) - -装上不等于能用。把配置指向 `127.0.0.1:9`(丢弃端口)实测:Agent 报连接失败说明配置生效,报别的说明没生效。 - -Codex 通——输出 `provider: oneagent`,证明它读了我们写的 `[model_providers.oneagent]`。 - -**Claude Code 不通**——报 `Not logged in`。它不从 `settings.json` 取认证,而 OneAgent 报的是 `status: configured` 并告知运行 `claude`。用户照做会撞墙,且没有线索指向 OneAgent。 - -根因是两处硬编码的集合: - -```python -if agent_id in {"codex", "opencode", "kilo-cli"}: - write_agent_env(...) +```bash +go test ./... +go test -race ./... +bash tests/install_test.sh +go run ./cmd/oneagent-release build --channel technical-preview-unsigned --source +go run ./cmd/oneagent-release check release ``` -Codex 能用正是因为它多一个 env 文件,而**唯一无法认证的 Agent 恰好是被这个集合漏掉的那个**。 - -修法是让 lock 声明凭据途径(`credential_delivery`:`oneagent_env` / `native_env` / `config_file`),Claude Code 另有 `env_vars` 声明它自己读的四个 `ANTHROPIC_*` 变量。`install_many`、`activate_agent`、`_next_step`、`_restart_hint` 都改为读声明,不再按 id 特判。实测确认按新的 `next` 指引启动后不再报 `Not logged in`。 - -**缺陷能存在,是因为没有任何测试问过密钥怎么到达 Agent**,只验了文件写没写。现在 `CredentialDeliveryTests` 遍历所有 auto Agent 要求凭据可达,`test_release_policy` 要求 lock 声明安装器依赖的字段。 - -## 前三条线交付时的状态 - -Python 211 用例,`installer.py` 288/288 分支且零 partial,整体 96%;前端 68 用例 + 14 个 e2e;站点 10 + 33;真实安装 cleanroom 双 registry 通过。 - -当时挂着三项待办:[配置链路审查](config-chain-audit.md) 的任务 2(消除剩余硬编码)与任务 3(无 Key 的配置可用性检查),以及 [按 Agent 分化配置界面](per-agent-config-plan.md) §6 的 Claude Code 双模型字段。**三项都已在下面两节完成**,最新数字见文末。 - -## 4. 三项待办收尾(`3c8335c`) - -上文「未完成」的三项已全部完成: - -- **任务 2(lock 唯一真源)**:`backups` 遍历 lock 按 `config_path` 推导;`provider_config_base` 改收推理协议、移除 `providers.py` 两处 `"claude-code"` 比较;Windows 门禁改读 `windows_prerequisites`。`test_release_policy.py` 新增 `LockIsTheSourceOfTruthTests` 遍历 lock 守护。 -- **任务 3(无 Key 配置可用性检查)**:新增 `scripts/agent_config_adopted_check.py`。分类器 `classify_adoption` 离线进常规 CI(`test_rc_scripts.py` 用本轮两个真实输出覆盖);实跑脚本无 Key、指向丢弃端口 `127.0.0.1:9`,已接入 `release-candidate.yml`。 -- **Claude Code 双模型字段(per-agent-config-plan §6)**:可选 `small_fast_model`(留空回退主模型)贯穿 `write_claude_config`/`write_agent_env`/`activate_agent`/activate 端点/`types/api.ts`;`AgentDetailPage` 高级选项加 Claude Code 专属「快速小模型」字段。 - -## 5. 入口分流:着陆页只给未配置用户(`dfff727`) - -一个并发编辑引入了着陆页,`/` 无条件渲染它。这让**返回用户打开 OneAgent 落在营销页而不是自己的环境总览**,等于把 `LandingRoute` 专门防的行为又放回来了,8 个从 `/` 进入的 e2e 因此失败。补一个漏掉的 `useWizard` import 只能让 `tsc` 干净,不解决行为问题。 - -修法是让根路径先读 status:已有 Agent 指向某处、或存在已激活档案,就跳 `/overview`;只有未配置的机器才看到着陆页。两处判断都要 status,所以 `WizardProvider` 上移到整个 router 之外;着陆页在 `AppWindow` 之外渲染——它是整页文档,不是应用窗口里的一个视图。 - -原实现做不到这个判断:`pathname` 检查在 Provider 外面,读不到状态。恢复的 `LandingRoute` 保留了等待首次 status 读取的那一步,理由和当初一样——fetch 在 effect 里启动,首帧 `statusState` 是 `idle` 而非 `loading`,不等就会让返回用户闪一下着陆页。 - -测试改法:`wizard.spec.ts` 五处入口改为 `/#/setup/agents`(它们测的是向导流程,不是路由决策),另补一条专测分流的用例,同时断言着陆页**不在** `.app-window` 内——把上面那个结构约束做成回归保护。`overview.spec.ts:264` 原样覆盖另一侧,两侧都有关卡。 - -## 当前测试现状 +需要真实网络和受保护凭据的检查只在 Release Candidate workflow 执行。Aider 的 Python 3.12 是其上游安装流程的可选外部前置条件,不属于 OneAgent 构建或发行包。 -Python 222 用例,`installer.py` 298/298 分支且零 partial,整体 96%;前端 71 单测 + 15 个 e2e(新增着陆页分流一条),覆盖率语句 100% / 分支 97%;`tsc --noEmit` 与 `vite build` 通过。真机确认已配置的机器访问 `/` 直达环境总览。 +## 设计结论 -## 相关文档 -- [空白机器可用性验证计划](blank-machine-verification-plan.md) —— 三层验证的设计与实测结论 -- [配置链路审查](config-chain-audit.md) —— 实测方法、硬编码清单与三项任务的落实情况 -- [公开分发站运营与发布手册](public-site-operations.md) -- [CC Switch 参考笔记](cc-switch-reference-notes.md) +- `agents.lock.json` 是 Agent 元数据唯一真源;Go catalog 不复制版本和来源。 +- shell wrapper 只定位已构建的 CLI,不按需构建、不调用解释器。 +- source map、远程资源、secret、Agent 二进制和任何语言 runtime 都不能进入发行 ZIP。 +- Wails Alpha 阶段只发布 `technical-preview-unsigned`;Stable 签名/公证另行验收。 diff --git a/docs/release-evidence/0.2.0-dev-macos-arm64.md b/docs/release-evidence/0.2.0-dev-macos-arm64.md index ba4a9030..568b55cc 100644 --- a/docs/release-evidence/0.2.0-dev-macos-arm64.md +++ b/docs/release-evidence/0.2.0-dev-macos-arm64.md @@ -1,9 +1,10 @@ -# OneAgent 0.2.0-dev macOS arm64 发行证据 +# OneAgent 0.2.0-dev macOS arm64 历史发行证据 + +> 历史记录:该包来自迁移前的预览流程,不代表当前发行实现。当前包由 `cmd/oneagent-release` 生成;本文件仅保留旧 SHA-256 以便审计。 状态:`technical-preview-unsigned`,不是 Stable。 - 构建平台:macOS arm64 -- Python:3.12.13 - 产物:`OneAgent-0.2.0-dev-technical-preview-unsigned-macos-arm64.zip` - SHA-256:`dad3f145b8710caf45c7de1cc8e8dd6fdfbdb00f6b6dc5cedf06dc49348cd46e` - Manifest 构建时间:2026-07-26T10:14:28.949648Z diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md new file mode 100644 index 00000000..c7baacb7 --- /dev/null +++ b/docs/wails-v3-migration-plan.md @@ -0,0 +1,123 @@ +# Wails v3 / Go 迁移收尾计划 + +> 状态:**已完成**(2026-07-31) +> +> 本文是收尾验收记录。当前生产实现是 Go + Wails + React;旧脚本和旧测试已删除。Wails 仍为 Alpha,所以发行渠道保持 `technical-preview-unsigned`。 + +## 1. 目标与边界 + +- 桌面应用使用 Wails v3,React 只调用生成的 TypeScript bindings。 +- headless CLI 和桌面 service 共用 `internal/` Go 用例。 +- Agent catalog、Provider、安装、配置发现、配置写入、profile、备份、权限和错误契约全部由 Go 实现。 +- 发行工具由 `cmd/oneagent-release` 统一负责;官网只读取 Release 与仓库 JSON。 +- Aider 的上游安装流程可以要求本机 Python 3.12,但这是用户选择 Aider 时的外部前置条件,不是 OneAgent 的构建、测试、运行或发行依赖。 + +## 2. 最终架构 + +```text +React + Vite + | +generated Wails bindings + | +Status / Provider / Agent / Profile services + | +Go application use cases + | +catalog / provider / install / config / profile / securefs / process + +cmd/oneagent headless CLI +cmd/oneagent-release native package + manifest/checks +cmd/oneagent-rc locked Agent RC checks +cmd/oneagent-provider-smoke Provider protocol RC checks +site/ independent Astro release site +``` + +生产桌面构建不使用 `server` tag,不监听业务端口。浏览器 E2E 才使用 Wails server/e2e runner。 + +## 3. 已完成的交付 + +### 核心迁移 + +- Go catalog 嵌入并校验 `agents.lock.json`。 +- Go Provider URL、模型发现和三协议 probe。 +- Go 安装编排、npm integrity 校验、registry 白名单和超时取消。 +- Go 配置适配器覆盖 Codex、Claude Code、OpenCode、Kilo CLI、Aider。 +- Go 配置发现、profile/secret 存储、原子写、备份和 Unix/Windows 权限。 +- Wails `StatusService`、`ProviderService`、`AgentService`、`ProfileService`。 +- React 页面和状态层切换到生成 binding;生产代码没有业务 `fetch` 或本地 HTTP API。 + +### 发行与 RC + +- `cmd/oneagent-release` 构建 React、Wails desktop 和纯 Go CLI。 +- 发行包生成 macOS `.app`、Windows/Linux ZIP、源码 ZIP、manifest、SHA-256 和第三方 notices。 +- release check 拒绝 source map、远程资源、secret、Agent 二进制、旧语言 runtime、wheel 和 PyInstaller 文件。 +- `cmd/oneagent-rc verify-agents` 在隔离 HOME/npm prefix 中真实安装锁定 npm Agent,并验证 PATH 和版本。 +- `cmd/oneagent-rc adopted` 将 Codex/Claude Code 指向丢弃端口,区分“配置已采用”和“认证未采用”。 +- `cmd/oneagent-provider-smoke` 复用 Go Provider client 验证 models、Chat、Responses、Anthropic Messages。 +- Docker 和 macOS cleanroom 只运行 Go/Node/shell 验收。 + +### 清理 + +已删除: + +- 旧 `oneagent/` 实现目录。 +- 旧 Python 测试、RC 脚本、GUI 和发布脚本。 +- `setup.py`、`pyproject.toml`、PyInstaller spec、wheel/resource staging。 +- CI/Docker 中的 setup-python、pip、coverage、PyInstaller 和 wheel 步骤。 + +## 4. 文件映射 + +| 旧职责 | 当前实现 | +| --- | --- | +| catalog、provider、installer、server | `internal/catalog`、`internal/provider`、`internal/app`、`internal/config` | +| 本地 GUI | `cmd/oneagent-desktop` + Wails | +| headless CLI | `cmd/oneagent` | +| release/build/check/notices | `cmd/oneagent-release` | +| locked Agent RC | `cmd/oneagent-rc verify-agents` | +| Provider RC | `cmd/oneagent-provider-smoke` | +| config adoption RC | `cmd/oneagent-rc adopted` | +| resource staging | `go:embed` / Wails assets | + +## 5. 验收命令 + +```bash +go vet ./... +go test ./... +go test -race ./... + +cd frontend +npm ci +npm run test:coverage +npm run build +npm run test:e2e +cd .. + +go build -o bin/oneagent ./cmd/oneagent +bash tests/install_test.sh +go run ./cmd/oneagent-release build --channel technical-preview-unsigned --source +go run ./cmd/oneagent-release check release +``` + +发行候选在受保护环境执行: + +```bash +go run ./cmd/oneagent-rc verify-agents +go run ./cmd/oneagent-rc adopted +go run ./cmd/oneagent-provider-smoke --provider all --timeout 30s +bash tests/real_install_test.sh +``` + +## 6. 最终门禁 + +- [x] 工作树中不再有受版本控制的旧实现文件。 +- [x] active workflow、Taskfile、Dockerfile 和 README 不调用旧脚本或旧打包工具。 +- [x] Go、React、Astro、Wails binding 和 native smoke 有独立验证入口。 +- [x] 无可选 runtime 的 PATH 下 Go CLI、Go tests、React build 和 release check 可运行。 +- [x] 发行包不含 `.py`、`.pyc`、wheel、PyInstaller 或 Agent 二进制。 +- [x] API Key 不进入 profile、日志、binding、URL 或测试附件。 +- [x] Wails Alpha 阶段只允许 `technical-preview-unsigned`。 +- [x] Aider 的 Python 3.12 只在选择 Aider 安装时作为外部 prerequisite 出现。 + +## 7. 兼容与回滚 + +用户的 `~/.oneagent`、Agent 配置路径、profile schema、secret 文件和 backup 命名保持兼容。回滚通过发布上一版已验收的原生包完成,不在新包中恢复已删除的旧 runtime。 diff --git a/frontend/.gitignore b/frontend/.gitignore index 5453c76f..c046ff1a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,5 +1,11 @@ node_modules/ -dist/ +# The bundle is ignored but dist/.keep is not: manifest_embed.go carries a +# `go:embed all:frontend/dist`, which does not compile when the directory is +# absent, so a fresh clone could not `go build` or `go vet` before Vite had run. +# Listed per-entry rather than as `dist/` because excluding the directory itself +# stops git descending into it, which would make the exception unreachable. +dist/* +!dist/.keep coverage/ test-results/ playwright-report/ diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts new file mode 100644 index 00000000..279015cf --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts @@ -0,0 +1,12 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + AgentStatus, + Capabilities, + DetectedConfig, + InstallRuntimeResult, + ProfileSummary, + RuntimeStatus, + StatusResponse +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts new file mode 100644 index 00000000..9ed981f9 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts @@ -0,0 +1,99 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as catalog$0 from "../catalog/models.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as install$0 from "../install/models.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as platform$0 from "../platform/models.js"; + +export interface AgentStatus { + "installed": boolean; + "configured": boolean; + "guideOnly": boolean; + "config": string; + "version": string | null; + "lockedVersion": string | null; + "canInstall": boolean; + "provider": string | null; + "profileId": string | null; + "model": string | null; + "baseUrl": string | null; + "updatedAt": string | null; + "detected": DetectedConfig | null; +} + +export interface Capabilities { + "canInstall": { [_ in string]?: boolean } | null; + + /** + * MissingRuntime names the runtime an Agent needs before it can be + * installed, keyed by Agent id. An entry means canInstall is false only + * because a bootstrappable runtime is absent, which the UI turns into a + * "install the runtime first" prompt rather than a dead end. + */ + "missingRuntime": { [_ in string]?: string } | null; + "supportedAgentIds": string[] | null; +} + +export interface DetectedConfig { + "baseUrl": string; + "model": string; + "managedByOneAgent": boolean; + "unreadable": string | null; +} + +/** + * InstallRuntimeResult reports what the bootstrap did. PathUpdated is separate + * from Installed because an already-downloaded runtime may still need its + * directory recorded on the login PATH. + */ +export interface InstallRuntimeResult { + "runtime": string; + "installed": boolean; + "version": string; + "pathUpdated": boolean; + "runtimes": RuntimeStatus[] | null; +} + +/** + * ProfileSummary is intentionally a public projection. It has no credential + * field; hasKey only reports whether a secret exists in the secure store. + */ +export interface ProfileSummary { + "id": string; + "label": string; + "provider": string; + "baseUrl": string | null; + "model": string | null; + "agentIds": string[] | null; + "activatedAt": string | null; + "hasKey": boolean; +} + +/** + * RuntimeStatus is the public projection of one bootstrappable runtime. + */ +export type RuntimeStatus = install$0.RuntimeState; + +export interface StatusResponse { + "apiVersion": number; + "platform": platform$0.Info; + "capabilities": Capabilities; + "agents": { [_ in string]?: AgentStatus } | null; + "catalog": catalog$0.CatalogItem[] | null; + "groups": catalog$0.Group[] | null; + "providers": { [_ in string]?: catalog$0.Provider } | null; + "mirrors": catalog$0.Mirror[] | null; + "paths": { [_ in string]?: string } | null; + "backups": { [_ in string]?: boolean } | null; + "profiles": ProfileSummary[] | null; + "activeProfile": string | null; + "runtimes": RuntimeStatus[] | null; + "environment": any; + "environmentError": string | null; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.ts new file mode 100644 index 00000000..58904d42 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.ts @@ -0,0 +1,18 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function Activate(request: $models.ActivateRequest): $CancellablePromise<$models.ActivateResponse> { + return $Call.ByID(1962001654, request); +} + +export function Install(request: $models.InstallRequest): $CancellablePromise<$models.InstallResponse> { + return $Call.ByID(3913480362, request); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts new file mode 100644 index 00000000..c67de357 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts @@ -0,0 +1,34 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as AgentService from "./agentservice.js"; +import * as ProfileService from "./profileservice.js"; +import * as ProviderService from "./providerservice.js"; +import * as RuntimeService from "./runtimeservice.js"; +import * as StatusService from "./statusservice.js"; +export { + AgentService, + ProfileService, + ProviderService, + RuntimeService, + StatusService +}; + +export type { + ActivateRequest, + ActivateResponse, + AgentInstallResult, + InstallRequest, + InstallResponse, + InstallRuntimeRequest, + ModelsRequest, + ModelsResponse, + OpenRegistrationRequest, + OpenRegistrationResponse, + ProbeRequest, + ProbeResponse, + ProviderIDRequest, + ProviderMutationResponse, + SaveProfileRequest, + SaveProviderRequest +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts new file mode 100644 index 00000000..9db5e448 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts @@ -0,0 +1,144 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export interface ActivateRequest { + "agent_id": string; + "provider": string; + "api_base_url": string; + "api_key": string; + "model": string; + "profile_id": string; + "small_fast_model": string; +} + +export interface ActivateResponse { + "ok": boolean; + "agent": string; + "config": string; + "provider": string; + "model": string; + "restart": string; + "next": string; +} + +export interface AgentInstallResult { + "agent": string; + "status": string; + "config"?: string | null; + "installed"?: boolean | null; + "version"?: string | null; + "lockedVersion"?: string | null; + "registry"?: string | null; + "code"?: number | null; + "error_code"?: string | null; + "message"?: string | null; + "retryable": boolean; +} + +export interface InstallRequest { + "agents": string[] | null; + "profile_agents": string[] | null; + "provider": string; + "api_base_url": string; + "api_key": string; + "model": string; + "small_fast_model": string; + "profile_id": string; + "configure": boolean; + "install_agent": boolean; + "locked_version": boolean; + "latest": boolean; + "skip_test": boolean; + "registry": string; + "timeout": number; +} + +export interface InstallResponse { + "ok": boolean; + "code": number; + "results": AgentInstallResult[] | null; + "log": string; + "next": string; + "probe": ProbeResponse | null; + "probes": { [_ in string]?: ProbeResponse } | null; +} + +export interface InstallRuntimeRequest { + "runtime": string; +} + +export interface ModelsRequest { + "provider": string; + "api_base_url": string; + "api_key": string; +} + +export interface ModelsResponse { + "ok": boolean; + "reachable": boolean; + "status": number; + "message": string; + "error_code": string | null; + "retryable": boolean; + "protocol"?: string | null; + "protocols"?: { [_ in string]?: ProbeResponse } | null; + "models": string[] | null; +} + +export interface OpenRegistrationRequest { + "provider": string; + "agents": string[] | null; +} + +export interface OpenRegistrationResponse { + "ok": boolean; + "url": string; + "message": string; +} + +export interface ProbeRequest { + "provider": string; + "api_base_url": string; + "api_key": string; + "model": string; + "agents": string[] | null; +} + +export interface ProbeResponse { + "ok": boolean; + "reachable": boolean; + "status": number; + "message": string; + "error_code": string | null; + "retryable": boolean; + "protocol"?: string | null; + "protocols"?: { [_ in string]?: ProbeResponse } | null; +} + +export interface ProviderIDRequest { + "id": string; +} + +export interface ProviderMutationResponse { + "ok": boolean; +} + +export interface SaveProfileRequest { + "id": string; + "label": string; + "provider": string; + "api_base_url": string; + "api_key": string; + "model": string; + "config_mode": string; + "agent_ids": string[] | null; +} + +export interface SaveProviderRequest { + "id": string; + "name": string; + "home": string; + "base_url": string; + "anthropic_base_url": string; + "api_key": string; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts new file mode 100644 index 00000000..7b851e68 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts @@ -0,0 +1,22 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as app$0 from "../app/models.js"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function ListProfiles(): $CancellablePromise { + return $Call.ByID(192725737); +} + +export function SaveProfile(request: $models.SaveProfileRequest): $CancellablePromise { + return $Call.ByID(972252911, request); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts new file mode 100644 index 00000000..04248a55 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts @@ -0,0 +1,38 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as provider$0 from "../provider/models.js"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function DeleteProvider(request: $models.ProviderIDRequest): $CancellablePromise<$models.ProviderMutationResponse> { + return $Call.ByID(116920211, request); +} + +export function GetProvider(request: $models.ProviderIDRequest): $CancellablePromise { + return $Call.ByID(36265922, request); +} + +export function ListModels(request: $models.ModelsRequest): $CancellablePromise<$models.ModelsResponse> { + return $Call.ByID(1201530915, request); +} + +export function OpenRegistration(request: $models.OpenRegistrationRequest): $CancellablePromise<$models.OpenRegistrationResponse> { + return $Call.ByID(745368128, request); +} + +export function Probe(request: $models.ProbeRequest): $CancellablePromise<$models.ProbeResponse> { + return $Call.ByID(2223638197, request); +} + +export function SaveProvider(request: $models.SaveProviderRequest): $CancellablePromise { + return $Call.ByID(2790810113, request); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/runtimeservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/runtimeservice.ts new file mode 100644 index 00000000..2240f36b --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/runtimeservice.ts @@ -0,0 +1,29 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * RuntimeService exposes the Node.js and uv bootstrap. It reuses the install + * output listener so the UI shows runtime downloads in the same log pane as + * Agent installs. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as app$0 from "../app/models.js"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function InstallRuntime(request: $models.InstallRuntimeRequest): $CancellablePromise { + return $Call.ByID(2509127327, request); +} + +export function ListRuntimes(): $CancellablePromise { + return $Call.ByID(1068151511); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.ts new file mode 100644 index 00000000..0d91fc57 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.ts @@ -0,0 +1,14 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as app$0 from "../app/models.js"; + +export function GetStatus(): $CancellablePromise { + return $Call.ByID(1555230240); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/index.ts new file mode 100644 index 00000000..462e2750 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/index.ts @@ -0,0 +1,9 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + CatalogItem, + Group, + Mirror, + Provider +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts new file mode 100644 index 00000000..da803d8b --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts @@ -0,0 +1,37 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export interface CatalogItem { + "id": string; + "name": string; + "group": string; + "configMode": string; + "guideOnly": boolean; + "lockedVersion": string | null; + "protocol": string | null; + "platforms": string[] | null; + "platformNote": string; + "rank": number; +} + +export interface Group { + "id": string; + "name": string; +} + +export interface Mirror { + "id": string; + "name": string; + "registry": string; + "upstream": string; + "note": string; +} + +export interface Provider { + "name": string; + "home": string; + "base_url": string; + "anthropic_base_url"?: string; + "custom"?: boolean; + "has_key"?: boolean; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/install/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/install/index.ts new file mode 100644 index 00000000..d00f36fc --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/install/index.ts @@ -0,0 +1,6 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + RuntimeState +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/install/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/install/models.ts new file mode 100644 index 00000000..29b01d0d --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/install/models.ts @@ -0,0 +1,24 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * RuntimeState is the public projection of one managed runtime. It carries no + * filesystem paths beyond the managed root so the UI cannot leak a private + * directory layout it should not depend on. + */ +export interface RuntimeState { + "id": string; + "name": string; + "command": string; + "installed": boolean; + "version": string; + "lockedVersion": string; + "managed": boolean; + "supported": boolean; + "note": string; + "license": string; + "licenseUrl": string; + "source": string; + "installPath": string; + "requiredByHint"?: string; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/index.ts new file mode 100644 index 00000000..2aad9941 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/index.ts @@ -0,0 +1,6 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + Info +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/models.ts new file mode 100644 index 00000000..daa33c6d --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/models.ts @@ -0,0 +1,8 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export interface Info { + "os": string; + "arch": string; + "shell": string; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/provider/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/provider/index.ts new file mode 100644 index 00000000..b88297d6 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/provider/index.ts @@ -0,0 +1,6 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + Entry +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/provider/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/provider/models.ts new file mode 100644 index 00000000..22164688 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/provider/models.ts @@ -0,0 +1,16 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Entry is the editable local Provider record. APIKey is returned only by the + * explicit Provider CRUD service; status projections expose HasKey instead. + */ +export interface Entry { + "id": string; + "name": string; + "home": string; + "base_url": string; + "anthropic_base_url": string; + "api_key": string; + "built_in": boolean; +} diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts new file mode 100644 index 00000000..1ea10585 --- /dev/null +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts @@ -0,0 +1,9 @@ +//@ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +Object.freeze($Create.Events); diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts new file mode 100644 index 00000000..3dd1807b --- /dev/null +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -0,0 +1,2 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT diff --git a/frontend/dist/.keep b/frontend/dist/.keep new file mode 100644 index 00000000..e69de29b diff --git a/frontend/e2e/overview.spec.ts b/frontend/e2e/overview.spec.ts deleted file mode 100644 index fd1bdb13..00000000 --- a/frontend/e2e/overview.spec.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { expect, test } from "@playwright/test"; -import type { Page, Route } from "@playwright/test"; - -import type { AgentStatus, StatusResponse } from "../src/types/api"; - -const AUTO_AGENTS = ["codex", "claude-code", "opencode", "kilo-cli", "aider"] as const; - -function agentStatus(over: Partial = {}): AgentStatus { - return { - installed: true, - configured: true, - guideOnly: false, - config: "/tmp/home/.config", - version: "1.0.0", - lockedVersion: "1.0.0", - canInstall: true, - provider: null, - model: null, - baseUrl: null, - updatedAt: null, - detected: null, - ...over, - }; -} - -/** Two Agents on different providers — the state per-Agent config exists for. */ -function divergentStatus(): StatusResponse { - return { - apiVersion: 1, - platform: { os: "macos", arch: "arm64", shell: "bash" }, - capabilities: { canInstall: {}, supportedAgentIds: [...AUTO_AGENTS] }, - agents: { - codex: agentStatus({ - provider: "ppio", - model: "deepseek/deepseek-v3", - baseUrl: "https://api.ppio.com/openai", - version: "0.144.4", - lockedVersion: "0.145.0", - }), - "claude-code": agentStatus({ provider: "novita", model: "qwen/qwen3-max" }), - opencode: agentStatus({ configured: false }), - "kilo-cli": agentStatus({ installed: false, configured: false }), - aider: agentStatus({ installed: false, configured: false }), - cursor: agentStatus({ guideOnly: true, configured: false }), - }, - catalog: [ - ...AUTO_AGENTS.map((id) => ({ - id, - name: id === "claude-code" ? "Claude Code" : id === "kilo-cli" ? "Kilo CLI" : id[0].toUpperCase() + id.slice(1), - group: "auto" as const, - configMode: "auto" as const, - guideOnly: false, - lockedVersion: id === "codex" ? "0.145.0" : "1.0.0", - protocol: id === "codex" ? ("responses" as const) : ("openai" as const), - platforms: ["macos" as const, "linux" as const, "windows" as const], - platformNote: "", - rank: { codex: 1, "claude-code": 2, opencode: 4, "kilo-cli": 8, aider: 9 }[id] ?? 99, - })), - { - id: "cursor", - name: "Cursor", - group: "ide" as const, - configMode: "guide" as const, - guideOnly: true, - lockedVersion: null, - protocol: null, - platforms: ["macos" as const], - platformNote: "按官方文档配置", - rank: 3, - }, - ], - groups: [ - { id: "auto", name: "One-click configurable" }, - { id: "gateway", name: "Gateway agents" }, - { id: "platform", name: "Official account agents" }, - { id: "ide", name: "IDE extensions" }, - ], - providers: { - ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" }, - novita: { name: "Novita", home: "https://novita.ai/", base_url: "https://api.novita.ai/openai" }, - }, - mirrors: [], - paths: { profile: "/tmp/home/.oneagent/profile.json" }, - backups: {}, - environment: null, - environmentError: null, - profiles: [], - activeProfile: null, - }; -} - -async function fulfillJson(route: Route, body: object, status = 200) { - await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); -} - -async function mockOverview(page: Page) { - const activateBodies: Record[] = []; - const activatePaths: string[] = []; - - await page.route("**/api/status", (route) => fulfillJson(route, divergentStatus())); - await page.route("**/api/probe", (route) => - fulfillJson(route, { - ok: true, - reachable: true, - status: 200, - message: "连接测试通过", - error_code: null, - retryable: false, - }), - ); - await page.route("**/api/agents/*/activate", (route) => { - activatePaths.push(new URL(route.request().url()).pathname); - activateBodies.push(route.request().postDataJSON() as Record); - return fulfillJson(route, { - ok: true, - agent: "codex", - config: "/tmp/home/.codex/config.toml", - provider: "novita", - model: "qwen/qwen3-max", - restart: "Quit any running codex process, then start it again", - next: "source ~/.oneagent/agents/codex.env && codex", - }); - }); - return { activateBodies, activatePaths }; -} - -test("总览按 Agent 分别显示各自的 Provider 与模型", async ({ page }, testInfo) => { - await mockOverview(page); - await page.goto("/#/overview"); - - // The whole point of per-Agent config: two Agents, two providers, at once. - await expect(page.getByText("PPIO", { exact: false }).first()).toBeVisible(); - await expect(page.getByText("qwen/qwen3-max")).toBeVisible(); - await expect(page.getByText("deepseek/deepseek-v3")).toBeVisible(); - - // Version drift reads inline on the Agent's own row: it is deferrable - // maintenance, not an alert that earns a banner at the top of the page. - await expect(page.getByText(/0\.144\.4 → 0\.145\.0/)).toBeVisible(); - await expect(page.getByText("未配置").first()).toBeVisible(); - - await page.screenshot({ path: testInfo.outputPath("overview-per-agent.png"), fullPage: true }); -}); - -test("配置在 Agent 独立页面完成,成功后给出重启指引", async ({ page }, testInfo) => { - const { activateBodies, activatePaths } = await mockOverview(page); - await page.goto("/#/overview"); - - // Configuring moved off the list: a form inside a row grew the list by its - // own height and allowed several rows to sit half-configured at once. - const codexRow = page.locator(".agent-manage-row").first(); - await expect(codexRow.getByLabel("API Key")).toHaveCount(0); - await codexRow.click(); - - await expect(page).toHaveURL(/#\/agents\/codex$/); - await expect(page.getByRole("heading", { name: "Codex" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath("detail-open.png"), fullPage: true }); - - const apply = page.getByRole("button", { name: /^应用/ }); - await expect(apply).toBeDisabled(); - - await page.getByLabel("API Key").fill("sentinel-detail-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(apply).toBeEnabled(); - - await apply.click(); - // An Agent reads its config at startup, so the switch is invisible until the - // process restarts; reporting success without that reads as a failure. - await expect(page.getByText(/Quit any running codex/)).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath("detail-applied.png"), fullPage: true }); - - expect(activatePaths).toEqual(["/api/agents/codex/activate"]); - expect(activateBodies).toHaveLength(1); - - await expect(page.getByLabel("API Key")).toHaveValue(""); - expect(await page.evaluate(() => ({ local: localStorage.length, session: sessionStorage.length }))).toEqual({ - local: 0, - session: 0, - }); -}); - -test("首屏用于 Agent 列表,而不是横幅与提醒", async ({ page }, testInfo) => { - await mockOverview(page); - await page.setViewportSize({ width: 1280, height: 860 }); - await page.goto("/#/overview"); - await page.waitForSelector(".agent-manage-row"); - - // The overview is opened every day, so its first screen belongs to the - // Agents. It used to spend 55% of the viewport on a one-off "ready" banner - // and a version reminder before the list even started. - const list = await page.locator(".agent-manage-list").boundingBox(); - expect(list).not.toBeNull(); - expect(list!.y).toBeLessThan(220); - - // The prominent Agents fit without scrolling. Kilo and Aider now sit behind - // the disclosure, so the count is the rank<=6 set rather than all five - // installable ones. - const rows = page.locator(".agent-manage-row"); - await expect(rows).toHaveCount(4); - const last = await rows.last().boundingBox(); - expect(last!.y + last!.height).toBeLessThan(860); - - // The dismissed banners are gone, not merely moved. - await expect(page.getByText("开发环境已就绪")).toHaveCount(0); - await expect(page.locator(".overview-notice")).toHaveCount(0); - - await page.screenshot({ path: testInfo.outputPath("overview-first-screen.png") }); -}); - -test("侧边栏每一项都能真的打开对应页面", async ({ page }, testInfo) => { - await mockOverview(page); - await page.goto("/#/overview"); - await page.waitForSelector(".agent-manage-row"); - - // Provider and 配置模板 used to point at wizard steps behind SetupGuard, so - // clicking them bounced back to step one and looked like a dead link. - for (const [label, hash, heading] of [ - ["Provider", "#/providers", "Provider"], - ["配置模板", "#/profiles", "配置模板"], - ["环境总览", "#/overview", "环境总览"], - ] as const) { - await page.getByRole("link", { name: label }).click(); - await expect(page).toHaveURL(new RegExp(hash.replace("/", "\\/"))); - await expect(page.getByRole("heading", { name: heading })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`nav-${heading}.png`), fullPage: true }); - } -}); - -test("Provider 页反查出每个服务正在被哪些 Agent 使用", async ({ page }) => { - await mockOverview(page); - await page.goto("/#/providers"); - const ppio = page.getByTestId("provider-ppio"); - await expect(ppio).toContainText("Codex"); - const novita = page.getByTestId("provider-novita"); - await expect(novita).toContainText("Claude Code"); -}); - -test("首屏按流行度排序,Cursor 在 Kilo 与 Aider 之前", async ({ page }, testInfo) => { - await mockOverview(page); - await page.goto("/#/overview"); - await page.waitForSelector(".agent-manage-row"); - - // The list is ranked by how widely an Agent is used, not by whether OneAgent - // can configure it. Cursor used to be hidden in a footnote while Kilo and - // Aider held the top of the page, which misrepresented what people run. - const names = await page.locator(".agent-manage-row strong").allTextContents(); - const order = names.map((n) => n.trim()); - expect(order.slice(0, 3)).toEqual(["Codex", "Claude Code", "Cursor"]); - // Kilo and Aider are not on the first screen at all now; they are behind the - // disclosure, which is the whole point of the ranking. - expect(order).not.toContain("Kilo CLI"); - expect(order).not.toContain("Aider"); - await page.getByRole("button", { name: /其他 Agent/ }).click(); - const all = (await page.locator(".agent-manage-row strong").allTextContents()).map((n) => n.trim()); - expect(all.indexOf("Cursor")).toBeLessThan(all.indexOf("Kilo CLI")); - expect(all.indexOf("Cursor")).toBeLessThan(all.indexOf("Aider")); - - // A guide-only Agent states how it is obtained rather than offering a form. - const cursorRow = page.locator(".agent-manage-row.is-guide").first(); - await expect(cursorRow).toContainText("按官方文档配置"); - await expect(cursorRow.getByLabel("API Key")).toHaveCount(0); - - await page.screenshot({ path: testInfo.outputPath("overview-ranked.png"), fullPage: true }); -}); - -test("已配置的环境直接进入总览而不是向导", async ({ page }) => { - await mockOverview(page); - await page.goto("/"); - // A returning user should not be sent back through first-run setup. - await expect(page).toHaveURL(/#\/overview$/); - await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); -}); diff --git a/frontend/e2e/wails-server.mjs b/frontend/e2e/wails-server.mjs new file mode 100644 index 00000000..2f6e1965 --- /dev/null +++ b/frontend/e2e/wails-server.mjs @@ -0,0 +1,32 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const frontendDir = dirname(fileURLToPath(import.meta.url)); +const root = join(frontendDir, "..", ".."); +const home = await mkdtemp(join(tmpdir(), "oneagent-wails-e2e-")); +const child = spawn("go", ["run", "-tags", "wails,server,e2e", "./cmd/oneagent-desktop"], { + cwd: root, + env: { + ...process.env, + ONEAGENT_HOME: home, + }, + stdio: "inherit", +}); + +let stopping = false; +const stop = () => { + if (!stopping) { + stopping = true; + child.kill(); + } +}; + +process.once("SIGINT", stop); +process.once("SIGTERM", stop); +child.once("exit", async (code) => { + await rm(home, { recursive: true, force: true }); + process.exit(code ?? 1); +}); diff --git a/frontend/e2e/wails.spec.ts b/frontend/e2e/wails.spec.ts new file mode 100644 index 00000000..fa4b0f38 --- /dev/null +++ b/frontend/e2e/wails.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; + +test("language selection switches to English and persists", async ({ page }) => { + await page.goto("/"); + await page.getByRole("combobox", { name: "语言" }).selectOption("en"); + await expect(page.getByRole("heading", { name: "Environment overview" })).toBeVisible(); + + await page.reload(); + await expect(page.getByRole("combobox", { name: "Language" })).toHaveValue("en"); +}); + +test("Profile management applies an environment and the overview stays read-only", async ({ page }) => { + const bindingMethodIDs = new Set(); + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (request.method() !== "POST" || path !== "/wails/runtime") return; + const call = request.postDataJSON() as { args?: { methodID?: unknown } }; + if (typeof call.args?.methodID === "number") bindingMethodIDs.add(call.args.methodID); + }); + + await page.goto("/"); + await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); + await expect(page.getByText("尚未安装任何 Agent")).toBeVisible(); + await expect(page.getByRole("button", { name: /开始配置|新建配置/ })).toHaveCount(0); + + await page.getByRole("link", { name: "配置模板" }).click(); + await page.getByRole("button", { name: "新增 Profile" }).click(); + await page.getByLabel("Profile ID").fill("team-ppio"); + await page.getByLabel("名称").fill("团队 PPIO"); + await page.getByLabel("模型", { exact: true }).fill("oneagent-e2e-model"); + await page.getByLabel("API Key").fill("e2e-key"); + await page.getByLabel("选择 Codex").check(); + await page.getByRole("button", { name: "保存 Profile" }).click(); + + const profile = page.getByTestId("profile-team-ppio"); + await expect(profile).toContainText("团队 PPIO"); + await profile.getByRole("button", { name: "编辑 团队 PPIO" }).click(); + await page.getByLabel("名称").fill("团队默认"); + await page.getByRole("button", { name: "保存 Profile" }).click(); + await expect(profile).toContainText("团队默认"); + await profile.getByRole("button", { name: "应用到 Agent" }).click(); + await expect(page.getByText(/已应用到 1 个 Agent/)).toBeVisible(); + + await page.getByRole("link", { name: "激活环境" }).click(); + await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); + const agent = page.getByTestId("agent-codex"); + await expect(agent).toContainText("PPIO"); + await expect(agent).toContainText("团队默认"); + expect(bindingMethodIDs.size).toBeGreaterThanOrEqual(3); +}); + +test("Provider CRUD persists keys", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Provider" }).click(); + await page.getByRole("button", { name: "新增 Provider" }).click(); + await expect(page.getByLabel("Provider ID")).toBeVisible(); + await page.getByLabel("Provider ID").fill("acme"); + await page.getByLabel("名称").fill("Acme"); + await page.getByLabel("OpenAI 兼容 Base URL").fill("https://api.acme.test/openai"); + await page.getByLabel("API Key").fill("sk-acme"); + await page.getByRole("button", { name: "保存", exact: true }).click(); + + const card = page.getByTestId("provider-acme"); + await expect(card).toContainText("已保存 Key"); + await page.getByRole("button", { name: "编辑 Acme" }).click(); + await expect(page.getByLabel("API Key")).toHaveValue("sk-acme"); + await page.getByRole("button", { name: "关闭编辑" }).click(); + + page.once("dialog", (dialog) => void dialog.accept()); + await page.getByRole("button", { name: "删除 Acme" }).click(); + await expect(page.getByTestId("provider-acme")).toHaveCount(0); +}); diff --git a/frontend/e2e/wizard.spec.ts b/frontend/e2e/wizard.spec.ts deleted file mode 100644 index d82a0256..00000000 --- a/frontend/e2e/wizard.spec.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { expect, test, type Page, type Route } from "@playwright/test"; - -import type { - AgentCatalogItem, - InstallResponse, - ModelsResponse, - ProbeResponse, - StatusResponse, -} from "../src/types/api"; - -// Typed so tsc flags drift between these mocks and the real API contract; the -// protocol values mirror ADAPTER_PROTOCOLS in oneagent/catalog.py. -const catalog: AgentCatalogItem[] = [ - { rank: 1, id: "codex", name: "Codex", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "0.145.0", protocol: "responses", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 2, id: "claude-code", name: "Claude Code", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "2.1.217", protocol: "anthropic", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 4, id: "opencode", name: "OpenCode", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "1.18.4", protocol: "openai", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 8, id: "kilo-cli", name: "Kilo CLI", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "7.4.11", protocol: "openai", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 9, id: "aider", name: "Aider", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "0.86.2", protocol: "openai", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 5, id: "openclaw", name: "OpenClaw", group: "gateway", configMode: "guide", guideOnly: true, lockedVersion: null, protocol: null, platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 3, id: "cursor", name: "Cursor", group: "platform", configMode: "guide", guideOnly: true, lockedVersion: null, protocol: null, platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 11, id: "cline", name: "Cline", group: "ide", configMode: "guide", guideOnly: true, lockedVersion: null, protocol: null, platforms: ["macos", "linux", "windows"], platformNote: "" }, -]; - -const agentStatuses = Object.fromEntries( - catalog.map((agent, index) => [ - agent.id, - { - installed: index === 0 || index === 2, - configured: false, - guideOnly: agent.guideOnly, - config: agent.guideOnly ? "" : `/tmp/home/.config/${agent.id}`, - version: index === 0 || index === 2 ? agent.lockedVersion : null, - lockedVersion: agent.lockedVersion, - canInstall: !agent.guideOnly, - provider: null, - model: null, - baseUrl: null, - updatedAt: null, - detected: null, - }, - ]), -); - -function statusPayload(activated: boolean): StatusResponse { - return { - apiVersion: 1, - platform: { os: "macos", arch: "arm64", shell: "bash" }, - capabilities: { canInstall: {}, supportedAgentIds: catalog.map((agent) => agent.id) }, - agents: agentStatuses, - catalog, - groups: [ - { id: "auto", name: "One-click configurable" }, - { id: "gateway", name: "Gateway agents" }, - { id: "platform", name: "Official account agents" }, - { id: "ide", name: "IDE extensions" }, - ], - providers: { - ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" }, - novita: { name: "Novita", home: "https://novita.ai/", base_url: "https://api.novita.ai/openai" }, - }, - mirrors: [], - paths: { - profile: "/tmp/home/.oneagent/profile.json", - codex_config: "/tmp/home/.codex/config.toml", - "claude-code_config": "/tmp/home/.claude/settings.json", - opencode_config: "/tmp/home/.config/opencode/opencode.jsonc", - }, - backups: {}, - environment: activated - ? { - schema_version: 1, - provider: "ppio", - base_url: "https://api.ppio.com/openai", - model: "deepseek-v3", - config_mode: "provider", - agent_ids: ["codex", "claude-code", "opencode"], - activated_at: "2026-07-22T00:00:00Z", - } - : null, - environmentError: null, - profiles: [], - activeProfile: null, - }; -} - -async function fulfillJson(route: Route, body: object, status = 200) { - await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); -} - -async function mockApi(page: Page, options: { failFirstInstall?: boolean } = {}) { - let activated = false; - let installCalls = 0; - const installBodies: Record[] = []; - const probeBodies: Record[] = []; - const modelBodies: Record[] = []; - - await page.route("**/api/status", (route) => fulfillJson(route, statusPayload(activated))); - await page.route("**/api/probe", (route) => { - probeBodies.push(route.request().postDataJSON() as Record); - return fulfillJson(route, { ok: true, reachable: true, status: 200, message: "连接测试通过", error_code: null, retryable: false } satisfies ProbeResponse); - }); - await page.route("**/api/models", (route) => { - modelBodies.push(route.request().postDataJSON() as Record); - return fulfillJson(route, { ok: true, reachable: true, status: 200, message: "Found 2 models.", error_code: null, retryable: false, models: ["deepseek-v3", "qwen3-coder"] } satisfies ModelsResponse); - }); - await page.route("**/api/open-register", (route) => fulfillJson(route, { ok: true, url: "https://ppio.com/", message: "opened" })); - await page.route("**/api/install", async (route) => { - installCalls += 1; - const body = route.request().postDataJSON() as Record; - installBodies.push(body); - await new Promise((resolve) => setTimeout(resolve, 120)); - const agents = body.agents as string[]; - if (options.failFirstInstall && installCalls === 1) { - await fulfillJson(route, { - ok: false, - code: 3, - results: agents.map((agent, index) => - index === 0 - ? { agent, status: "failed" as const, error_code: "PREREQUISITE_MISSING", message: "npm is required", retryable: true } - : { agent, status: "configured" as const, retryable: false }, - ), - log: "redacted install log", - next: "", - probe: null, - } satisfies InstallResponse); - return; - } - activated = true; - await fulfillJson(route, { - ok: true, - code: 0, - results: agents.map((agent) => ({ agent, status: body.configure ? ("configured" as const) : ("skipped" as const), retryable: false })), - log: "redacted install log", - next: "source ~/.oneagent/agents/codex.env && codex", - probe: body.configure - ? { ok: true, reachable: true, status: 200, message: "Connection test passed.", error_code: null, retryable: false } - : null, - } satisfies InstallResponse); - }); - - return { installBodies, modelBodies, probeBodies }; -} - -async function expectNoHorizontalOverflow(page: Page) { - const sizes = await page.evaluate(() => ({ body: document.body.scrollWidth, viewport: window.innerWidth })); - expect(sizes.body).toBeLessThanOrEqual(sizes.viewport); -} - -for (const viewport of [ - { width: 1440, height: 900, label: "1440x900" }, - { width: 1280, height: 800, label: "1280x800" }, - { width: 1024, height: 720, label: "1024x720" }, -]) { - test(`完整七页流程 ${viewport.label}`, async ({ page }, testInfo) => { - await page.setViewportSize(viewport); - await mockApi(page); - await page.goto("/#/setup/agents"); - await expect(page.getByRole("heading", { name: "选择 Agent" })).toBeVisible(); - // Ranked, not grouped: leading with the "auto" group used to put Kilo and - // Aider here and fold Cursor and OpenClaw out of sight. - await expect(page.locator(".agent-row input[type=checkbox]").first()).toHaveAttribute( - "aria-label", - "选择 Codex", - ); - const firstScreen = await page.locator(".content-section .agent-row").allTextContents(); - expect(firstScreen.join(" ")).toContain("Cursor"); - expect(firstScreen.join(" ")).not.toContain("Aider"); - // Guide-only rows stay selectable; install_many answers them with a guide. - await expect(page.getByRole("checkbox", { name: "选择 Cursor" })).toBeEnabled(); - await page.screenshot({ path: testInfo.outputPath(`01-agents-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("checkbox", { name: "选择 Claude Code" }).check(); - await page.getByRole("checkbox", { name: "选择 OpenCode" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "配置方式" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`02-mode-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("button", { name: /配置模型服务/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "连接模型服务" })).toBeVisible(); - // The endpoint note must aggregate the protocols of the selected Agents. - await expect(page.getByText(/Anthropic Messages \+ OpenAI Chat Completions \+ OpenAI Responses/)).toBeVisible(); - await page.getByLabel("API Key").fill("sentinel-browser-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(page.getByText("连接测试通过")).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`03-provider-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("button", { name: "继续选择模型" }).click(); - await expect(page.getByRole("heading", { name: "选择模型" })).toBeVisible(); - await expect(page.getByRole("radio", { name: /deepseek-v3/ })).toBeChecked(); - await page.screenshot({ path: testInfo.outputPath(`04-model-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "确认激活" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`05-review-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`06-result-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - expect(await page.evaluate(() => ({ local: localStorage.length, session: sessionStorage.length }))).toEqual({ local: 0, session: 0 }); - expect(await page.content()).not.toContain("sentinel-browser-secret"); - - await page.getByRole("button", { name: "进入总览" }).click(); - // Arriving at the overview is what matters here. The old assertion looked - // for a "ready" banner, which was a one-off wizard confirmation occupying - // the top of a page the user opens every day; the Agent list is the page. - await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); - await expect(page.locator(".agent-manage-row").first()).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`07-overview-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - }); -} - -test("浏览器后退到激活页不重放安装", async ({ page }) => { - const mock = await mockApi(page); - await page.goto("/#/setup/agents"); - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: /配置模型服务/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByLabel("API Key").fill("backtrack-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(page.getByText("连接测试通过")).toBeVisible(); - await page.getByRole("button", { name: "继续选择模型" }).click(); - await expect(page.getByRole("heading", { name: "选择模型" })).toBeVisible(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - await page.getByRole("button", { name: "进入总览" }).click(); - await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); - - await page.goBack(); - // The outcome page must come back as a static summary: same heading, no - // second /api/install fired with the (now cleared) key. - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - await page.waitForTimeout(300); - expect(mock.installBodies).toHaveLength(1); -}); - -test("已有账号路径跳过 Provider 和模型", async ({ page }) => { - const mock = await mockApi(page); - await page.goto("/#/setup/agents"); - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: /使用已有账号或配置/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "确认激活" })).toBeVisible(); - await expect(page.getByText("已跳过")).toHaveCount(2); - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - expect(mock.installBodies[0]).toMatchObject({ configure: false, api_key: "", agents: ["codex"] }); -}); - -test("切回内置 Provider 后不提交残留 Custom URL", async ({ page }) => { - const mock = await mockApi(page); - await page.goto("/#/setup/agents"); - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: /配置模型服务/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "连接模型服务" })).toBeVisible(); - await page.getByRole("radio", { name: "自定义", exact: true }).click(); - await page.getByLabel("Base URL").fill("http://127.0.0.1:9900/openai"); - await page.getByRole("radio", { name: "PPIO", exact: true }).click(); - await page.getByLabel("API Key").fill("provider-switch-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(page.getByText("连接测试通过")).toBeVisible(); - await page.getByRole("button", { name: "继续选择模型" }).click(); - await expect(page.getByRole("heading", { name: "选择模型" })).toBeVisible(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - expect(mock.probeBodies[0]).toMatchObject({ provider: "ppio", api_base_url: "" }); - expect(mock.modelBodies[0]).toMatchObject({ provider: "ppio", api_base_url: "" }); - expect(mock.installBodies[0]).toMatchObject({ provider: "ppio", api_base_url: "" }); -}); - -test("失败 Agent 可以单独重试且不重复成功项", async ({ page }) => { - const mock = await mockApi(page, { failFirstInstall: true }); - await page.goto("/#/setup/agents"); - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("checkbox", { name: "选择 OpenCode" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: /配置模型服务/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByLabel("API Key").fill("retry-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(page.getByText("连接测试通过")).toBeVisible(); - await page.getByRole("button", { name: "继续选择模型" }).click(); - await expect(page.getByRole("heading", { name: "选择模型" })).toBeVisible(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "需要处理部分问题" })).toBeVisible(); - await page.getByRole("button", { name: "重试" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - expect(mock.installBodies).toHaveLength(2); - expect(mock.installBodies[1]).toMatchObject({ agents: ["codex"], profile_agents: ["codex", "opencode"] }); -}); - -test("未配置时根路径展示着陆页,且入口通向向导", async ({ page }) => { - // The landing page exists for someone who has not configured anything. A - // returning user is sent to their own overview instead (overview.spec.ts - // covers that half); this is the other side of the same decision. - await mockApi(page); - await page.goto("/"); - await expect(page.getByRole("link", { name: /Open OneAgent/ }).first()).toBeVisible(); - // It renders as its own document, not inside the app's window chrome. - await expect(page.locator(".app-window")).toHaveCount(0); - await page.getByRole("link", { name: /Open workspace/ }).first().click(); - await expect(page.getByRole("heading", { name: "选择 Agent" })).toBeVisible(); -}); diff --git a/frontend/index.html b/frontend/index.html index dd20cb9e..8b6de84b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,9 @@ - - + + + OneAgent — every agent, one clear lane diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 41bc8865..2ee89d6a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "oneagent-frontend", "version": "0.2.0-dev", "dependencies": { + "@wailsio/runtime": "3.0.0-alpha2.117", "lucide-react": "1.25.0", "react": "19.2.8", "react-dom": "19.2.8", @@ -1399,6 +1400,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wailsio/runtime": { + "version": "3.0.0-alpha2.117", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha2.117.tgz", + "integrity": "sha512-RZr6cncIXjdTbn2IqJ6AZXPm9WooaZEsNlNfaUH+Ru/YlH5sKDDeRdWpbcvsgoMLh4AJ+O8aBzZEiTYWDipGxw==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 07f683fc..13d7b653 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,10 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", - "e2e": "playwright test" + "test:e2e": "playwright test" }, "dependencies": { + "@wailsio/runtime": "3.0.0-alpha2.117", "lucide-react": "1.25.0", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index f46b8cdd..cff213da 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,31 +1,23 @@ import { defineConfig } from "@playwright/test"; -const pythonCommand = process.platform === "win32" ? "python ..\\scripts\\gui.py --port 8765 --no-open" : "python3 ../scripts/gui.py --port 8765 --no-open"; +const port = Number(process.env.ONEAGENT_E2E_PORT || 34123); export default defineConfig({ testDir: "./e2e", - fullyParallel: false, - timeout: 30_000, - expect: { timeout: 5_000 }, - reporter: [["list"]], + workers: 1, use: { - baseURL: "http://127.0.0.1:8765", - browserName: "chromium", + baseURL: `http://127.0.0.1:${port}`, + locale: "zh-CN", trace: "retain-on-failure", - screenshot: "only-on-failure", }, webServer: { - command: pythonCommand, - cwd: process.cwd(), - url: "http://127.0.0.1:8765/", - reuseExistingServer: true, - timeout: 30_000, - // Surface the Python GUI's own output. Without this a webServer startup - // failure reports only "Timed out waiting 30000ms" with no cause. - stdout: "pipe", - stderr: "pipe", + command: "npm run build && node ./e2e/wails-server.mjs", + url: `http://127.0.0.1:${port}/health`, + timeout: 120_000, + reuseExistingServer: false, env: { - ONEAGENT_DISABLE_BROWSER: "1", + WAILS_SERVER_HOST: "127.0.0.1", + WAILS_SERVER_PORT: String(port), }, }, }); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..84156855 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1933 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@wailsio/runtime': + specifier: 3.0.0-alpha2.117 + version: 3.0.0-alpha2.117 + lucide-react: + specifier: 1.25.0 + version: 1.25.0(react@19.2.8) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + react-router-dom: + specifier: 7.18.1 + version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 + '@testing-library/jest-dom': + specifier: 7.0.0 + version: 7.0.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: 14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + '@types/react': + specifier: 19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: 6.0.3 + version: 6.0.3(vite@8.1.5(@types/node@26.1.1)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + jsdom: + specifier: 29.1.1 + version: 29.1.1 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@26.1.1) + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@exodus/bytes@1.15.1': + 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 + + '@jest/types@27.0.2': + resolution: {integrity: sha512-XpjCtJ/99HB4PmyJ2vgmN7vT+JLP7RW1FBT9RgnMFS4Dt7cvIyBee8O3/j98aUZ34ZpenPZFqmaaObWSeL65dg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.4.14': + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.0': + resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^2.0.0-alpha.3 + '@emnapi/runtime': ^2.0.0-alpha.3 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.0': + resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@16.0.11': + resolution: {integrity: sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@wailsio/runtime@3.0.0-alpha2.117': + resolution: {integrity: sha512-RZr6cncIXjdTbn2IqJ6AZXPm9WooaZEsNlNfaUH+Ru/YlH5sKDDeRdWpbcvsgoMLh4AJ+O8aBzZEiTYWDipGxw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.1.0: + resolution: {integrity: sha512-Qts4KCLKG+waHc9C4m07weIY8qyeixoS0h6RnbsNVD6Fw+pEZGW3vTyObL3WXpE09Mq4Oi7/lBEyLmOiLtlYWQ==} + engines: {node: '>=8'} + + ansi-styles@5.0.0: + resolution: {integrity: sha512-6564t0m0fuQMnockqBv7wJxo9T5C2V9JpYXyNScfRDPVLusOQQhkpMGrFC17QbiolraQ1sMXX+Y5nJpjqozL4g==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.0.0: + resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.0.1: + resolution: {integrity: sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==} + engines: {node: '>=18'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lucide-react@1.25.0: + resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + 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==} + engines: {node: '>=12.20.0'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.0.2: + resolution: {integrity: sha512-mXKbbBPnYTG7Yra9qFBtqj+IXcsvxsvOBco3QHxtxTl+hHKq6QdzMZ+q0CtL4ORHZgwGImRr2XZUX2EWzORxig==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.1: + resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==} + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@7.1.0: + resolution: {integrity: sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.1': {} + + '@jest/types@27.0.2': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 26.1.1 + '@types/yargs': 16.0.11 + chalk: 4.0.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.4.14': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + + '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.0.2 + + '@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@16.0.11': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@26.1.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@26.1.1) + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@26.1.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@wailsio/runtime@3.0.0-alpha2.117': {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.1.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.0.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + chai@6.2.2: {} + + chalk@4.0.0: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + convert-source-map@2.0.0: {} + + cookie@1.0.1: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + has-flag@4.0.0: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + indent-string@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.1.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@11.5.2: {} + + lucide-react@1.25.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.24: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.0.2: + dependencies: + '@jest/types': 27.0.2 + ansi-regex: 5.0.1 + ansi-styles: 5.0.0 + react-is: 17.0.1 + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.1: {} + + react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.0.1 + react: 19.2.8 + set-cookie-parser: 2.6.0 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.6.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@7.1.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + undici@7.29.0: {} + + vite@8.1.5(@types/node@26.1.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.24 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.5(@types/node@26.1.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d3ca026f..5e2c0518 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,6 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AppWindow } from "./components/AppWindow"; -import { LandingPage } from "./pages/LandingPage"; import { ActivationPage } from "./pages/ActivationPage"; import { AgentDetailPage } from "./pages/AgentDetailPage"; import { AgentSelectionPage } from "./pages/AgentSelectionPage"; @@ -12,6 +11,7 @@ import { ProfilesPage } from "./pages/ProfilesPage"; import { ProviderKeyPage } from "./pages/ProviderKeyPage"; import { ProvidersPage } from "./pages/ProvidersPage"; import { ReviewPage } from "./pages/ReviewPage"; +import { I18nProvider } from "./i18n"; import { WizardProvider, useWizard } from "./state/WizardContext"; function SetupGuard({ stage, children }: { stage: "mode" | "provider" | "model" | "review" | "activation"; children: React.ReactNode }) { @@ -39,33 +39,11 @@ function SetupGuard({ stage, children }: { stage: "mode" | "provider" | "model" return children; } -function LandingRoute() { - const { state } = useWizard(); - // Wait for the first status read before choosing. The fetch starts in an - // effect, so the initial render has no status and a state of "idle" rather - // than "loading" — treating that as "nothing configured" would show the - // landing page to a returning user before their Agents ever loaded. - if (!state.status && state.statusState !== "error") { - return
正在读取环境状态
; - } - // An Agent already pointed somewhere, or a previously activated profile, - // means this is a returning user: send them to their own environment. The - // landing page is for someone who has not configured anything yet, and has - // nothing to tell a user whose Agents are already running. - const configured = - Boolean(state.status?.environment) || - Object.values(state.status?.agents ?? {}).some((agent) => agent.provider); - if (configured) { - return ; - } - return ; -} - -/** The product itself: every route that belongs inside the app window. */ function WorkspaceRoutes() { return ( + } /> } /> } /> } /> @@ -75,6 +53,7 @@ function WorkspaceRoutes() { } /> } /> } /> + } /> } /> } /> @@ -83,15 +62,11 @@ function WorkspaceRoutes() { } export default function App() { - // One provider around both, because "/" has to read status to know whether - // this is a returning user. The landing page renders outside AppWindow: it is - // a full-page document, not a view inside the app's window chrome. return ( - - - } /> - } /> - - + + + + + ); } diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts deleted file mode 100644 index 6421fb8c..00000000 --- a/frontend/src/api/client.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { api, describeError, OneAgentApiError } from "./client"; - -function jsonResponse(payload: object, status = 200) { - return new Response(JSON.stringify(payload), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -describe("api client", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("loads status with same-origin credentials", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ apiVersion: 1 })); - await expect(api.status()).resolves.toEqual({ apiVersion: 1 }); - expect(fetchMock).toHaveBeenCalledWith( - "/api/status", - expect.objectContaining({ credentials: "same-origin" }), - ); - }); - - it("maps structured server errors", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - jsonResponse({ message: "bad origin", error_code: "INVALID_ORIGIN", retryable: false }, 403), - ); - await expect(api.status()).rejects.toMatchObject({ - message: "bad origin", - code: "INVALID_ORIGIN", - retryable: false, - status: 403, - }); - }); - - it("falls back through error, then a generic message", async () => { - // A proxy or a crash can return a body without the structured fields; the - // wizard must still surface something actionable rather than "undefined". - vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "boom" }, 500)); - await expect(api.status()).rejects.toMatchObject({ - message: "boom", - code: "INTERNAL_ERROR", - retryable: false, - status: 500, - }); - }); - - it("survives an error body with no recognisable fields", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({}, 502)); - await expect(api.status()).rejects.toMatchObject({ - message: "OneAgent request failed", - code: "INTERNAL_ERROR", - status: 502, - }); - }); - - it("wraps a network failure instead of leaking the raw TypeError", async () => { - // The common cause is the local GUI process having exited; the user must - // see an actionable Chinese message, not "Failed to fetch". - vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); - await expect(api.status()).rejects.toMatchObject({ - name: "OneAgentApiError", - message: expect.stringContaining("无法连接本机 OneAgent 服务"), - retryable: true, - }); - }); - - it("wraps a non-JSON response body", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response("proxy error", { status: 502, headers: { "Content-Type": "text/html" } }), - ); - await expect(api.status()).rejects.toMatchObject({ - name: "OneAgentApiError", - message: expect.stringContaining("HTTP 502"), - status: 502, - }); - }); - - it("describes errors preserving the API contract, with a fallback otherwise", () => { - const apiError = new OneAgentApiError("key rejected", "API_KEY_REJECTED", false, 401); - expect(describeError(apiError, "fallback")).toEqual({ - message: "key rejected", - code: "API_KEY_REJECTED", - retryable: false, - }); - expect(describeError(new Error("boom"), "fallback")).toEqual({ - message: "boom", - code: "INTERNAL_ERROR", - retryable: true, - }); - expect(describeError("not-an-error", "fallback")).toEqual({ - message: "fallback", - code: "INTERNAL_ERROR", - retryable: true, - }); - }); - - it("posts provider requests using API field names", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - jsonResponse({ ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false }), - ); - await api.probe({ provider: "custom", apiBaseUrl: "http://127.0.0.1:9000", apiKey: "sentinel", model: "model-a" }); - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(init.method).toBe("POST"); - expect(JSON.parse(String(init.body))).toEqual({ - provider: "custom", - api_base_url: "http://127.0.0.1:9000", - api_key: "sentinel", - model: "model-a", - }); - }); - - it("sends the selected agents so each protocol is probed", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - jsonResponse({ ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false }), - ); - await api.probe({ - provider: "custom", - apiBaseUrl: "http://127.0.0.1:9000", - apiKey: "sentinel", - model: "model-a", - agents: ["codex", "opencode"], - }); - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body)).agents).toEqual(["codex", "opencode"]); - }); - - it("omits agents entirely when none are selected", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - jsonResponse({ ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false }), - ); - await api.probe({ provider: "ppio", apiBaseUrl: "", apiKey: "sentinel", model: "m", agents: [] }); - const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); - expect(body).not.toHaveProperty("agents"); - }); - - it("includes small_fast_model on activate only when provided", async () => { - // A fresh response per call: a Response body can only be read once, and this - // test exercises activate twice. - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(() => - Promise.resolve( - jsonResponse({ ok: true, agent: "claude-code", config: "/c", provider: "ppio", model: "m", restart: "r", next: "n" }), - ), - ); - await api.activateAgent("claude-code", { - provider: "ppio", - apiBaseUrl: "", - apiKey: "sentinel", - model: "model-a", - smallFastModel: "model-fast", - }); - let body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); - expect(body.small_fast_model).toBe("model-fast"); - - // Empty falls back to the main model on the backend, so the field is omitted - // rather than sent blank. - await api.activateAgent("claude-code", { - provider: "ppio", - apiBaseUrl: "", - apiKey: "sentinel", - model: "model-a", - }); - body = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)); - expect(body).not.toHaveProperty("small_fast_model"); - }); - - it("supports models, install and register endpoints", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(jsonResponse({ ok: true, models: ["a"] })) - .mockResolvedValueOnce(jsonResponse({ ok: true, code: 0, results: [], log: "", next: "", probe: null })) - .mockResolvedValueOnce(jsonResponse({ ok: true, url: "https://ppio.com/", message: "opened" })); - - await api.models({ provider: "ppio", apiBaseUrl: "", apiKey: "key" }); - await api.install({ - agents: ["codex"], - provider: "ppio", - api_key: "key", - model: "model-a", - configure: true, - install_agent: false, - skip_test: true, - }); - await api.openRegister("ppio", ["codex"]); - - expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/models", "/api/install", "/api/open-register"]); - }); -}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 544286a6..00000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { - ActivateAgentResponse, - InstallRequest, - InstallResponse, - ModelsResponse, - ProbeResponse, - ProviderId, - StatusResponse, -} from "../types/api"; - -export class OneAgentApiError extends Error { - readonly code: string; - readonly retryable: boolean; - readonly status: number; - - constructor(message: string, code: string, retryable: boolean, status: number) { - super(message); - this.name = "OneAgentApiError"; - this.code = code; - this.retryable = retryable; - this.status = status; - } -} - -export interface FailureDetail { - message: string; - code: string; - retryable: boolean; -} - -/** Normalise any thrown value into the API error contract, so callers keep - * the backend's error_code/retryable instead of hard-coding replacements. */ -export function describeError(error: unknown, fallback: string): FailureDetail { - if (error instanceof OneAgentApiError) { - return { message: error.message, code: error.code, retryable: error.retryable }; - } - return { message: error instanceof Error ? error.message : fallback, code: "INTERNAL_ERROR", retryable: true }; -} - -async function request(path: string, init?: RequestInit): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - credentials: "same-origin", - headers: { - "Content-Type": "application/json", - ...init?.headers, - }, - }); - } catch { - // fetch rejects with an opaque English TypeError on network failure; the - // usual cause for this loopback-only app is the GUI process having exited. - throw new OneAgentApiError("无法连接本机 OneAgent 服务,请确认它仍在运行", "INTERNAL_ERROR", true, 0); - } - let payload: T & { - message?: string; - error?: string; - error_code?: string; - retryable?: boolean; - }; - try { - payload = (await response.json()) as typeof payload; - } catch { - throw new OneAgentApiError(`服务响应异常(HTTP ${response.status})`, "INTERNAL_ERROR", false, response.status); - } - if (!response.ok) { - throw new OneAgentApiError( - payload.message || payload.error || "OneAgent request failed", - payload.error_code || "INTERNAL_ERROR", - Boolean(payload.retryable), - response.status, - ); - } - return payload; -} - -function post(path: string, body: object): Promise { - return request(path, { method: "POST", body: JSON.stringify(body) }); -} - -export const api = { - status: () => request("/api/status"), - probe: (input: { - provider: ProviderId; - apiBaseUrl: string; - apiKey: string; - model: string; - /** Selected Agents, so each one's protocol is exercised rather than - * assuming OpenAI Chat Completions for everything. */ - agents?: string[]; - }) => - post("/api/probe", { - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - model: input.model, - ...(input.agents?.length ? { agents: input.agents } : {}), - }), - models: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string }) => - post("/api/models", { - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - }), - install: (input: InstallRequest) => post("/api/install", input), - openRegister: (provider: Exclude, agents: string[]) => - post<{ ok: true; url: string; message: string }>("/api/open-register", { provider, agents }), - /** Repoint one Agent. Only that Agent's config and credential file change. */ - activateAgent: ( - agentId: string, - input: { - provider: ProviderId; - apiBaseUrl: string; - apiKey: string; - model: string; - profileId?: string; - smallFastModel?: string; - }, - ) => - post(`/api/agents/${encodeURIComponent(agentId)}/activate`, { - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - model: input.model, - ...(input.profileId ? { profile_id: input.profileId } : {}), - ...(input.smallFastModel ? { small_fast_model: input.smallFastModel } : {}), - }), -}; diff --git a/frontend/src/backend/api.ts b/frontend/src/backend/api.ts new file mode 100644 index 00000000..862a638a --- /dev/null +++ b/frontend/src/backend/api.ts @@ -0,0 +1,16 @@ +import { wailsApi } from "./wails"; +import { OneAgentApiError, describeError } from "./errors"; + +/** + * The single page-facing backend surface. + * + * Every call goes through the generated Wails bindings to a registered Go + * service. There is no HTTP transport and no runtime transport selection: the + * desktop app does not open a business port, so a bridge failure is a real + * failure rather than something to retry over a second channel. + */ +export type BackendApi = typeof wailsApi; + +export const api: BackendApi = wailsApi; + +export { OneAgentApiError, describeError }; diff --git a/frontend/src/backend/errors.ts b/frontend/src/backend/errors.ts new file mode 100644 index 00000000..aadc13e9 --- /dev/null +++ b/frontend/src/backend/errors.ts @@ -0,0 +1,31 @@ +export class OneAgentApiError extends Error { + readonly code: string; + readonly retryable: boolean; + readonly status: number; + + constructor(message: string, code: string, retryable: boolean, status: number) { + super(message); + this.name = "OneAgentApiError"; + this.code = code; + this.retryable = retryable; + this.status = status; + } +} + +export interface FailureDetail { + message: string; + code: string; + retryable: boolean; +} + +/** Normalize any thrown value into the stable frontend error contract. */ +export function describeError(error: unknown, fallback: string): FailureDetail { + if (error instanceof OneAgentApiError) { + return { message: error.message, code: error.code, retryable: error.retryable }; + } + return { + message: error instanceof Error ? error.message : fallback, + code: "INTERNAL_ERROR", + retryable: true, + }; +} diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts new file mode 100644 index 00000000..746734cb --- /dev/null +++ b/frontend/src/backend/wails.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { InstallResponse, ModelsResponse, ProbeResponse, ProfileSummary, ProviderEntry, StatusResponse } from "../types/api"; +import { LOCALE_STORAGE_KEY } from "../i18n"; + +const bridge = vi.hoisted(() => ({ + status: vi.fn(), + probe: vi.fn(), + models: vi.fn(), + getProvider: vi.fn(), + saveProvider: vi.fn(), + deleteProvider: vi.fn(), + install: vi.fn(), + register: vi.fn(), + activate: vi.fn(), + profiles: vi.fn(), + saveProfile: vi.fn(), + eventsOn: vi.fn(), +})); + +vi.mock("@wailsio/runtime", () => ({ Events: { On: bridge.eventsOn } })); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js", () => ({ GetStatus: bridge.status })); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js", () => ({ + Probe: bridge.probe, + ListModels: bridge.models, + OpenRegistration: bridge.register, + GetProvider: bridge.getProvider, + SaveProvider: bridge.saveProvider, + DeleteProvider: bridge.deleteProvider, +})); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.js", () => ({ + Install: bridge.install, + Activate: bridge.activate, +})); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js", () => ({ + ListProfiles: bridge.profiles, + SaveProfile: bridge.saveProfile, +})); + +import { INSTALL_OUTPUT_EVENT, normalizeWailsError, onInstallOutput, wailsApi } from "./wails"; + +describe("Wails backend adapter", () => { + afterEach(() => vi.resetAllMocks()); + + it("forwards the page-facing calls to generated bindings", async () => { + const status = { + apiVersion: 1, + platform: { os: "linux", arch: "arm64", shell: "bash" }, + runtimes: [], + capabilities: { canInstall: {}, missingRuntime: {}, supportedAgentIds: [] }, + agents: {}, catalog: [], groups: [], providers: {}, mirrors: [], paths: {}, backups: {}, + profiles: [], activeProfile: null, environment: null, environmentError: null, + } satisfies StatusResponse; + const probe = { ok: true, reachable: true, status: 204, message: "ok", error_code: null, retryable: false } satisfies ProbeResponse; + const models = { ...probe, models: ["model-a"] } satisfies ModelsResponse; + const install = { ok: true, code: 0, results: [], log: "", next: "", probe: null } satisfies InstallResponse; + const profile = { id: "team", label: "Team", provider: "ppio", baseUrl: null, model: "m", agentIds: ["codex"], activatedAt: null, hasKey: true } satisfies ProfileSummary; + const provider = { id: "acme", name: "Acme", home: "", base_url: "https://api.acme.test", anthropic_base_url: "", api_key: "secret", built_in: false } satisfies ProviderEntry; + + bridge.status.mockResolvedValue(status); + bridge.probe.mockResolvedValue(probe); + bridge.models.mockResolvedValue(models); + bridge.install.mockResolvedValue(install); + bridge.register.mockResolvedValue({ ok: true, url: "https://ppio.com/", message: "opened" }); + bridge.activate.mockResolvedValue({ ok: true, agent: "codex", config: "/c", provider: "ppio", model: "m", restart: "restart", next: "next" }); + bridge.profiles.mockResolvedValue([profile]); + bridge.saveProfile.mockResolvedValue(profile); + bridge.getProvider.mockResolvedValue(provider); + bridge.saveProvider.mockResolvedValue(provider); + bridge.deleteProvider.mockResolvedValue({ ok: true }); + + await expect(wailsApi.status()).resolves.toBe(status); + await expect(wailsApi.probe({ provider: "custom", apiBaseUrl: "https://proxy.test/v1", apiKey: "secret", model: "m", agents: [] })).resolves.toBe(probe); + await expect(wailsApi.models({ provider: "ppio", apiBaseUrl: "", apiKey: "secret" })).resolves.toBe(models); + await expect(wailsApi.getProvider("acme")).resolves.toBe(provider); + await expect(wailsApi.saveProvider({ id: "acme", name: "Acme", home: "", base_url: "https://api.acme.test", anthropic_base_url: "", api_key: "secret" })).resolves.toBe(provider); + await expect(wailsApi.deleteProvider("acme")).resolves.toBeUndefined(); + await expect(wailsApi.install({ agents: ["codex"], provider: "ppio", api_key: "secret", model: "m", configure: true, install_agent: false, skip_test: true })).resolves.toBe(install); + await wailsApi.openRegister("ppio", []); + await wailsApi.activateAgent("codex", { provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m" }); + await expect(wailsApi.listProfiles()).resolves.toEqual([profile]); + await expect(wailsApi.saveProfile({ id: "team", label: "Team", provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m", configMode: "provider", agentIds: ["codex"] })).resolves.toBe(profile); + + expect(bridge.probe).toHaveBeenCalledWith({ provider: "custom", api_base_url: "https://proxy.test/v1", api_key: "secret", model: "m", agents: null }); + expect(bridge.getProvider).toHaveBeenCalledWith({ id: "acme" }); + expect(bridge.deleteProvider).toHaveBeenCalledWith({ id: "acme" }); + expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ agents: ["codex"], profile_agents: null, timeout: 180, latest: false })); + expect(bridge.register).toHaveBeenCalledWith({ provider: "ppio", agents: null }); + expect(bridge.activate).toHaveBeenCalledWith(expect.objectContaining({ agent_id: "codex", profile_id: "", small_fast_model: "" })); + expect(bridge.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ api_base_url: "", api_key: "secret", agent_ids: ["codex"] })); + }); + + it("restores structured Wails errors without exposing raw bridge details", async () => { + expect(normalizeWailsError({ cause: { error_code: "API_KEY_REJECTED", message: "key rejected", status: 401, retryable: false } })).toMatchObject({ + message: "key rejected", code: "API_KEY_REJECTED", status: 401, retryable: false, + }); + expect(normalizeWailsError({ cause: '{"error_code":"TIMEOUT","message":"probe timed out","status":504,"retryable":true}' })).toMatchObject({ + message: "probe timed out", code: "TIMEOUT", status: 504, retryable: true, + }); + localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + expect(normalizeWailsError(new Error("secret-key-value"))).toMatchObject({ + message: "无法调用本机 OneAgent 服务", code: "INTERNAL_ERROR", status: 500, retryable: true, + }); + }); + + it("subscribes to and filters installation output events", () => { + const unsubscribe = vi.fn(); + const listener = vi.fn(); + bridge.eventsOn.mockImplementation((_name, callback) => { + callback({ data: { kind: "command", args: ["npm"] } }); + callback({ data: { kind: "output", stream: "stdout", text: "ready" } }); + callback({ data: null }); + callback({ data: "ignored" }); + callback({ data: { kind: "other" } }); + return unsubscribe; + }); + + expect(onInstallOutput(listener)).toBe(unsubscribe); + expect(bridge.eventsOn).toHaveBeenCalledWith(INSTALL_OUTPUT_EVENT, expect.any(Function)); + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts new file mode 100644 index 00000000..58ba5ff4 --- /dev/null +++ b/frontend/src/backend/wails.ts @@ -0,0 +1,167 @@ +import { Events } from "@wailsio/runtime"; + +import * as AgentService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.js"; +import * as ProfileService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js"; +import * as ProviderService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js"; +import * as RuntimeService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/runtimeservice.js"; +import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js"; +import type { + ActivateAgentResponse, + InstallRequest, + InstallOutput, + InstallResponse, + InstallRuntimeResult, + ModelsResponse, + OpenRegistrationResponse, + ProbeResponse, + ProviderEntry, + ProfileSummary, + ProviderId, + RuntimeStatus, + SaveProviderInput, + StatusResponse, +} from "../types/api"; +import { currentLocale, translate } from "../i18n"; +import { OneAgentApiError } from "./errors"; + +export { OneAgentApiError, describeError } from "./errors"; + +export const INSTALL_OUTPUT_EVENT = "oneagent:install-output"; + +export function onInstallOutput(listener: (output: InstallOutput) => void): () => void { + return Events.On(INSTALL_OUTPUT_EVENT, (event) => { + const data = event.data; + if (!data || typeof data !== "object") return; + const kind = (data as { kind?: unknown }).kind; + if (kind === "command" || kind === "output") listener(data as InstallOutput); + }); +} + +type ErrorCause = Record; + +function causeOf(error: unknown): ErrorCause { + const cause = error && typeof error === "object" ? (error as { cause?: unknown }).cause : undefined; + if (typeof cause === "string") { + try { + const parsed: unknown = JSON.parse(cause); + return parsed && typeof parsed === "object" ? (parsed as ErrorCause) : {}; + } catch { + return {}; + } + } + return cause && typeof cause === "object" ? (cause as ErrorCause) : {}; +} + +function stringValue(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} + +function numberValue(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +/** Convert a Wails bridge rejection into the stable frontend error contract. */ +export function normalizeWailsError(error: unknown): OneAgentApiError { + if (error instanceof OneAgentApiError) return error; + const cause = causeOf(error); + const known = Object.keys(cause).length > 0; + return new OneAgentApiError( + known ? stringValue(cause.message, translate(currentLocale(), "OneAgent 请求失败")) : translate(currentLocale(), "无法调用本机 OneAgent 服务"), + known ? stringValue(cause.error_code, "INTERNAL_ERROR") : "INTERNAL_ERROR", + known ? cause.retryable === true : true, + known ? numberValue(cause.status, 500) : 500, + ); +} + +async function call(operation: () => PromiseLike): Promise { + try { + return await operation(); + } catch (error) { + throw normalizeWailsError(error); + } +} + +export const wailsApi = { + onInstallOutput, + status: (): Promise => call(() => StatusService.GetStatus()) as Promise, + probe: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string; model: string; agents?: string[] }): Promise => + call(() => ProviderService.Probe({ + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + agents: input.agents?.length ? input.agents : null, + })) as Promise, + models: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string }): Promise => + call(() => ProviderService.ListModels({ + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + })) as Promise, + getProvider: (id: string): Promise => + call(() => ProviderService.GetProvider({ id })) as Promise, + saveProvider: (input: SaveProviderInput): Promise => + call(() => ProviderService.SaveProvider(input)) as Promise, + deleteProvider: (id: string): Promise => + call(() => ProviderService.DeleteProvider({ id })).then(() => undefined), + install: (input: InstallRequest): Promise => + call(() => AgentService.Install({ + agents: input.agents, + profile_agents: input.profile_agents ?? null, + provider: input.provider, + api_base_url: input.api_base_url ?? "", + api_key: input.api_key, + model: input.model, + small_fast_model: input.small_fast_model ?? "", + profile_id: input.profile_id ?? "", + configure: input.configure, + install_agent: input.install_agent, + locked_version: input.locked_version ?? false, + latest: input.latest ?? false, + skip_test: input.skip_test, + registry: input.registry ?? "", + timeout: input.timeout ?? 180, + })) as Promise, + openRegister: (provider: ProviderId, agents: string[]): Promise => + call(() => ProviderService.OpenRegistration({ provider, agents: agents.length ? agents : null })) as Promise, + activateAgent: ( + agentId: string, + input: { provider: ProviderId; apiBaseUrl: string; apiKey: string; model: string; profileId?: string; smallFastModel?: string }, + ): Promise => + call(() => AgentService.Activate({ + agent_id: agentId, + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + profile_id: input.profileId ?? "", + small_fast_model: input.smallFastModel ?? "", + })) as Promise, + listRuntimes: (): Promise => + call(() => RuntimeService.ListRuntimes()).then((runtimes) => runtimes ?? []), + installRuntime: (runtime: string): Promise => + call(() => RuntimeService.InstallRuntime({ runtime })) as Promise, + listProfiles: (): Promise => call(() => ProfileService.ListProfiles()) as Promise, + saveProfile: (input: { + id: string; + label: string; + provider: ProviderId; + apiBaseUrl: string; + apiKey: string; + model: string; + configMode: string; + agentIds: string[]; + }): Promise => + call(() => ProfileService.SaveProfile({ + id: input.id, + label: input.label, + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + config_mode: input.configMode, + agent_ids: input.agentIds, + })) as Promise, +}; + +export type WailsApi = typeof wailsApi; diff --git a/frontend/src/components/AdvancedSection.tsx b/frontend/src/components/AdvancedSection.tsx index d40dfb2f..935d1ede 100644 --- a/frontend/src/components/AdvancedSection.tsx +++ b/frontend/src/components/AdvancedSection.tsx @@ -2,6 +2,8 @@ import { ChevronDown } from "lucide-react"; import { useState } from "react"; import type { PropsWithChildren } from "react"; +import { useI18n } from "../i18n"; + /** * Collapsed-by-default container for options most users should not touch. * @@ -11,15 +13,16 @@ import type { PropsWithChildren } from "react"; * wondering whether they skipped something they needed. */ export function AdvancedSection({ - label = "高级选项", + label, hint, children, }: PropsWithChildren<{ label?: string; hint?: string }>) { const [open, setOpen] = useState(false); + const { t } = useI18n(); return (
{!open && hint ?

{hint}

: null} diff --git a/frontend/src/components/AgentManageRow.test.tsx b/frontend/src/components/AgentManageRow.test.tsx index 141ec219..c78497dc 100644 --- a/frontend/src/components/AgentManageRow.test.tsx +++ b/frontend/src/components/AgentManageRow.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; import type { AgentCatalogItem, AgentStatus } from "../types/api"; import { AgentManageRow, compareVersions, isBehind, targetSummary } from "./AgentManageRow"; @@ -27,6 +27,7 @@ function agentStatus(over: Partial = {}): AgentStatus { lockedVersion: "0.145.0", canInstall: true, provider: "ppio", + profileId: "team", model: "deepseek/deepseek-v3", baseUrl: "https://api.ppio.com/openai", updatedAt: "2026-07-27T00:00:00Z", @@ -35,7 +36,7 @@ function agentStatus(over: Partial = {}): AgentStatus { }; } -function renderRow(over: Partial = {}, onOpen = vi.fn()) { +function renderRow(over: Partial = {}, profileName = "团队 PPIO") { render( = {}, onOpen = vi.fn()) { providers={{ ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" }, }} - onOpen={onOpen} + profileName={profileName} />, ); - return onOpen; } describe("AgentManageRow", () => { it("shows what the Agent is pointed at", () => { renderRow(); expect(screen.getByText("Codex")).toBeTruthy(); - expect(screen.getByText(/PPIO/)).toBeTruthy(); + expect(screen.getByText("PPIO", { selector: ".agent-manage-fact > span" })).toBeTruthy(); expect(screen.getByText(/deepseek\/deepseek-v3/)).toBeTruthy(); + expect(screen.getByText("团队 PPIO")).toBeTruthy(); }); it("distinguishes an unconfigured Agent from a configured one", () => { - renderRow({ configured: false, provider: null, model: null, baseUrl: null }); - expect(screen.getByText("未配置")).toBeTruthy(); + renderRow({ configured: false, provider: null, profileId: null, model: null, baseUrl: null }, ""); + expect(screen.getAllByText("未记录")).toHaveLength(2); + expect(screen.getByText("未绑定")).toBeTruthy(); }); it("flags a version behind the locked one", () => { @@ -90,14 +92,11 @@ describe("AgentManageRow", () => { expect(isBehind("2.1.220", "2.1.217")).toBe(false); }); - it("delegates configuration instead of editing in place", () => { - // The form moved to /agents/:agentId. Keeping it here grew the list by a - // whole form's height and let several rows sit half-configured at once. - const onOpen = renderRow(); + it("is informational rather than a configuration entry point", () => { + renderRow(); expect(screen.queryByLabelText(/API Key/i)).toBeNull(); expect(screen.queryByRole("button", { name: /测试连接/ })).toBeNull(); - fireEvent.click(screen.getByRole("button", { name: /Codex/ })); - expect(onOpen).toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /Codex/ })).toBeNull(); }); }); diff --git a/frontend/src/components/AgentManageRow.tsx b/frontend/src/components/AgentManageRow.tsx index 7e221a4f..e027ff34 100644 --- a/frontend/src/components/AgentManageRow.tsx +++ b/frontend/src/components/AgentManageRow.tsx @@ -1,5 +1,4 @@ -import { ChevronRight } from "lucide-react"; - +import { sourceTranslate, type Translate, useI18n } from "../i18n"; import type { AgentCatalogItem, AgentStatus, StatusResponse } from "../types/api"; import { AgentIcon, agentTagline } from "./icons/agents"; import { StatusBadge } from "./StatusBadge"; @@ -24,7 +23,7 @@ export function isBehind(installed: string, locked: string): boolean { return compareVersions(installed, locked) < 0; } -export function versionNote(status: AgentStatus): { text: string; behind: boolean } | null { +export function versionNote(status: AgentStatus, t: Translate = sourceTranslate): { text: string; behind: boolean } | null { if (!status.installed || !status.version) return null; if (!status.lockedVersion || status.version === status.lockedVersion) { // Being current is the normal case and needs no words: the bare version is @@ -38,7 +37,7 @@ export function versionNote(status: AgentStatus): { text: string; behind: boolea // The arrow already says which way this goes. return { text: `${status.version} → ${status.lockedVersion}`, behind: true }; } - return { text: `${status.version}(锁定 ${status.lockedVersion})`, behind: false }; + return { text: t("{version}(锁定 {lockedVersion})", { version: status.version, lockedVersion: status.lockedVersion }), behind: false }; } /** @@ -52,10 +51,11 @@ export function versionNote(status: AgentStatus): { text: string; behind: boolea export function targetSummary( status: AgentStatus, providers: Providers, + t: Translate = sourceTranslate, ): { text: string; note: string } { const detected = status.detected; if (detected?.unreadable) { - return { text: "配置无法解析", note: detected.unreadable }; + return { text: t("配置无法解析"), note: detected.unreadable }; } const providerName = status.provider ? providers[status.provider]?.name || status.provider : ""; // Ours, and the file agrees (or has nothing to add). @@ -64,7 +64,7 @@ export function targetSummary( detected && detected.baseUrl && status.baseUrl && detected.baseUrl !== status.baseUrl; return { text: `${providerName} · ${status.model}`, - note: drifted ? `配置文件当前指向 ${detected!.baseUrl}` : "", + note: drifted ? t("配置文件当前指向 {url}", { url: detected!.baseUrl }) : "", }; } // No record of our own, but the file says something. @@ -72,56 +72,54 @@ export function targetSummary( const parts = [detected.baseUrl, detected.model].filter(Boolean); return { text: parts.join(" · "), - note: detected.managedByOneAgent ? "" : "检测到的配置,非 OneAgent 写入", + note: detected.managedByOneAgent ? "" : t("检测到的配置,非 OneAgent 写入"), }; } - return { text: "未配置", note: "" }; + return { text: t("未配置"), note: "" }; } /** - * One row in the overview: what this Agent points at, nothing editable. - * - * Configuration lives on /agents/:agentId. Editing inline used to grow the list - * by the height of a whole form, let several rows sit half-configured at once, - * and left no room for the file paths and backup state a detail view can show. + * One read-only row in the environment overview. */ export function AgentManageRow({ agentId, catalog, status, providers, - onOpen, + profileName, }: { agentId: string; catalog: AgentCatalogItem | undefined; status: AgentStatus; providers: Providers; - onOpen: () => void; + profileName: string; }) { - const version = versionNote(status); - const target = targetSummary(status, providers); - const configuredSomehow = - Boolean(status.provider) || Boolean(status.detected?.baseUrl) || Boolean(status.detected?.model); - const action = !status.installed ? "安装并配置" : configuredSomehow ? "改配置" : "配置"; + const { t } = useI18n(); + const version = versionNote(status, t); + const target = targetSummary(status, providers, t); + const providerName = status.provider + ? providers[status.provider]?.name || status.provider + : status.detected?.baseUrl || t("未记录"); + const model = status.model || status.detected?.model || t("未记录"); return ( - + ); } diff --git a/frontend/src/components/AgentProgressRow.tsx b/frontend/src/components/AgentProgressRow.tsx index d30124ad..f8b275cf 100644 --- a/frontend/src/components/AgentProgressRow.tsx +++ b/frontend/src/components/AgentProgressRow.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, CheckCircle2, Circle, LoaderCircle, RotateCcw } from "lucide-react"; +import { useI18n } from "../i18n"; import type { AgentInstallResult } from "../types/api"; import { StatusBadge } from "./StatusBadge"; @@ -14,8 +15,18 @@ export function AgentProgressRow({ loading: boolean; onRetry?: () => void; }) { + const { t } = useI18n(); const failed = result?.status === "failed"; const complete = result && !failed && result.status !== "skipped"; + const resultStatus = result?.status === "failed" + ? t("失败") + : result?.status === "skipped" + ? t("已跳过") + : result?.status === "guide-only" + ? t("仅引导") + : result?.status === "configured" || result?.status === "installed" + ? t("已完成") + : result?.status; return (
{name} - {loading ? "正在处理" : result?.message || result?.status || "等待执行"} + {loading ? t("正在处理") : result?.message || resultStatus || t("等待执行")} {failed && onRetry ? ( ) : complete ? ( - 已完成 + {t("已完成")} ) : result?.status === "guide-only" ? ( - 仅引导 + {t("仅引导")} ) : null}
); diff --git a/frontend/src/components/AgentRow.tsx b/frontend/src/components/AgentRow.tsx index b8049492..26a82c75 100644 --- a/frontend/src/components/AgentRow.tsx +++ b/frontend/src/components/AgentRow.tsx @@ -1,3 +1,4 @@ +import { useI18n } from "../i18n"; import type { AgentCatalogItem, AgentStatus } from "../types/api"; import { AgentIcon, agentTagline } from "./icons/agents"; import { StatusBadge } from "./StatusBadge"; @@ -10,8 +11,9 @@ interface AgentRowProps { } export function AgentRow({ agent, status, selected, onToggle }: AgentRowProps) { + const { t } = useI18n(); const supported = agent.platforms.length > 0; - const statusLabel = status?.installed ? "已安装" : agent.guideOnly ? "仅引导" : "待安装"; + const statusLabel = status?.installed ? t("已安装") : agent.guideOnly ? t("仅引导") : t("待安装"); const statusTone = status?.installed ? "success" : agent.guideOnly ? "neutral" : "warning"; return ( @@ -21,9 +23,9 @@ export function AgentRow({ agent, status, selected, onToggle }: AgentRowProps) { checked={selected} onChange={onToggle} disabled={!supported} - aria-label={`选择 ${agent.name}`} + aria-label={t("选择 {name}", { name: agent.name })} /> - + @@ -31,7 +33,7 @@ export function AgentRow({ agent, status, selected, onToggle }: AgentRowProps) { {agent.name} {agent.lockedVersion ? v{agent.lockedVersion} : null} - {agent.guideOnly ? "显示官方安装与配置步骤" : "支持检测、安装与初始化配置"} + {agent.guideOnly ? t("显示官方安装与配置步骤") : t("支持检测、安装与初始化配置")} {agent.platformNote ? {agent.platformNote} : null} {statusLabel} diff --git a/frontend/src/components/AppWindow.tsx b/frontend/src/components/AppWindow.tsx index 0e30d8a3..92b62121 100644 --- a/frontend/src/components/AppWindow.tsx +++ b/frontend/src/components/AppWindow.tsx @@ -6,12 +6,6 @@ export function AppWindow({ children }: PropsWithChildren) { return (
- -
OneAgent
{children}
diff --git a/frontend/src/components/ConnectionStatus.tsx b/frontend/src/components/ConnectionStatus.tsx index 99d57651..5e1a49ae 100644 --- a/frontend/src/components/ConnectionStatus.tsx +++ b/frontend/src/components/ConnectionStatus.tsx @@ -1,14 +1,16 @@ import { AlertCircle, CheckCircle2, LoaderCircle, Radio, ShieldAlert } from "lucide-react"; +import { useI18n } from "../i18n"; import type { AsyncState } from "../state/wizardReducer"; import type { ProbeResponse } from "../types/api"; export function ConnectionStatus({ state, result }: { state: AsyncState; result: ProbeResponse | null }) { + const { t } = useI18n(); if (state === "idle") { return (
- 尚未测试连接 + {t("尚未测试连接")}
); } @@ -16,7 +18,7 @@ export function ConnectionStatus({ state, result }: { state: AsyncState; result: return (
- 正在验证端点和 Key + {t("正在验证端点和 Key")}
); } @@ -32,7 +34,7 @@ export function ConnectionStatus({ state, result }: { state: AsyncState; result: return (
{rejected ? : } - {result?.message || "连接失败"} + {result?.message || t("连接失败")}
); } diff --git a/frontend/src/components/GuideOnlyRow.tsx b/frontend/src/components/GuideOnlyRow.tsx index 88e58ce5..b9ee228f 100644 --- a/frontend/src/components/GuideOnlyRow.tsx +++ b/frontend/src/components/GuideOnlyRow.tsx @@ -1,5 +1,6 @@ import { ArrowUpRight } from "lucide-react"; +import { useI18n } from "../i18n"; import type { AgentCatalogItem, AgentStatus } from "../types/api"; import { AgentIcon, agentTagline } from "./icons/agents"; import { StatusBadge } from "./StatusBadge"; @@ -25,23 +26,24 @@ export function GuideOnlyRow({ catalog: AgentCatalogItem; status: AgentStatus | undefined; }) { + const { t } = useI18n(); const detected = Boolean(status?.installed); return (
- + {catalog.name} - {catalog.platformNote || "按官方方式安装与登录"} + {catalog.platformNote || t("按官方方式安装与登录")} - {detected ? "已检测到" : "官方安装"} + {detected ? t("已检测到") : t("官方安装")} - 官方文档 + {t("官方文档")}
diff --git a/frontend/src/components/LogDisclosure.tsx b/frontend/src/components/LogDisclosure.tsx index 08cfd0a1..88cb5049 100644 --- a/frontend/src/components/LogDisclosure.tsx +++ b/frontend/src/components/LogDisclosure.tsx @@ -1,14 +1,20 @@ import { ChevronDown, TerminalSquare } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; -export function LogDisclosure({ log }: { log: string }) { - const [open, setOpen] = useState(false); +import { useI18n } from "../i18n"; + +export function LogDisclosure({ log, open: openByParent = false }: { log: string; open?: boolean }) { + const { t } = useI18n(); + const [open, setOpen] = useState(openByParent); + useEffect(() => { + if (openByParent) setOpen(true); + }, [openByParent]); if (!log) return null; return (
{open ?
{log}
: null} diff --git a/frontend/src/components/ModelPicker.tsx b/frontend/src/components/ModelPicker.tsx index b8e84c44..efe54d80 100644 --- a/frontend/src/components/ModelPicker.tsx +++ b/frontend/src/components/ModelPicker.tsx @@ -1,6 +1,8 @@ import { Check, Search } from "lucide-react"; import { useMemo, useState } from "react"; +import { useI18n } from "../i18n"; + interface ModelPickerProps { models: string[]; value: string; @@ -8,6 +10,7 @@ interface ModelPickerProps { } export function ModelPicker({ models, value, onChange }: ModelPickerProps) { + const { t } = useI18n(); const [query, setQuery] = useState(""); const filtered = useMemo( () => models.filter((model) => model.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), @@ -20,9 +23,9 @@ export function ModelPicker({ models, value, onChange }: ModelPickerProps) { <>
- setQuery(event.target.value)} placeholder="搜索模型" aria-label="搜索模型" /> + setQuery(event.target.value)} placeholder={t("搜索模型")} aria-label={t("搜索模型")} />
-
+
{filtered.map((model) => ( ))} - {!filtered.length ?
没有匹配的模型
: null} + {!filtered.length ?
{t("没有匹配的模型")}
: null}
) : null}
- - onChange(event.target.value)} placeholder="例如 gpt-4.1" /> + + onChange(event.target.value)} placeholder={t("例如 gpt-4.1")} />
); diff --git a/frontend/src/components/NavigationSidebar.tsx b/frontend/src/components/NavigationSidebar.tsx index 7ef955e4..c03791ce 100644 --- a/frontend/src/components/NavigationSidebar.tsx +++ b/frontend/src/components/NavigationSidebar.tsx @@ -1,16 +1,19 @@ -import { Boxes, FolderCog, Gauge, Layers3, Sparkles } from "lucide-react"; +import { Boxes, FolderCog, Gauge, Languages, Layers3 } from "lucide-react"; import { NavLink } from "react-router-dom"; +import { type TranslationKey, useI18n } from "../i18n"; + // Only real destinations belong here. /setup/* are wizard steps behind // SetupGuard: listing them made the sidebar look broken, because clicking one // without a selected Agent bounced straight back to the first step. -const navItems = [ - { to: "/overview", label: "环境总览", icon: Gauge }, +const navItems: Array<{ to: string; label: TranslationKey | "Provider"; icon: typeof Gauge }> = [ + { to: "/overview", label: "激活环境", icon: Gauge }, { to: "/providers", label: "Provider", icon: Layers3 }, { to: "/profiles", label: "配置模板", icon: FolderCog }, ]; export function NavigationSidebar() { + const { locale, setLocale, t } = useI18n(); return ( ); } diff --git a/frontend/src/components/PageScaffold.tsx b/frontend/src/components/PageScaffold.tsx index b5f258f7..ce79b916 100644 --- a/frontend/src/components/PageScaffold.tsx +++ b/frontend/src/components/PageScaffold.tsx @@ -1,6 +1,7 @@ import { ArrowLeft, ArrowRight } from "lucide-react"; import type { PropsWithChildren, ReactNode } from "react"; +import { useI18n } from "../i18n"; import { SetupStepper } from "./SetupStepper"; interface PageScaffoldProps extends PropsWithChildren { @@ -28,7 +29,7 @@ export function PageScaffold({ description, stepper, footerNote, - backLabel = "返回", + backLabel, onBack, primaryLabel, onPrimary, @@ -38,6 +39,7 @@ export function PageScaffold({ bodyClassName = "", children, }: PageScaffoldProps) { + const { t } = useI18n(); return (
@@ -56,7 +58,7 @@ export function PageScaffold({ {onBack ? ( ) : null} {footerNote ?
{footerNote}
: null} diff --git a/frontend/src/components/ProviderSegment.tsx b/frontend/src/components/ProviderSegment.tsx index 14b8fac2..fd0111f1 100644 --- a/frontend/src/components/ProviderSegment.tsx +++ b/frontend/src/components/ProviderSegment.tsx @@ -1,26 +1,33 @@ -import type { ProviderId } from "../types/api"; +import { Plus } from "lucide-react"; -const providers: Array<{ id: ProviderId; label: string }> = [ - { id: "ppio", label: "PPIO" }, - { id: "novita", label: "Novita" }, - { id: "custom", label: "自定义" }, -]; +import { useI18n } from "../i18n"; +import type { ProviderId, StatusResponse } from "../types/api"; -export function ProviderSegment({ value, onChange }: { value: ProviderId; onChange: (value: ProviderId) => void }) { +export function ProviderSegment({ + value, + providers, + onAdd, + onChange, +}: { + value: ProviderId; + providers: StatusResponse["providers"]; + onAdd: () => void; + onChange: (value: ProviderId) => void; +}) { + const { t } = useI18n(); return ( -
- {providers.map((provider) => ( - - ))} +
); } diff --git a/frontend/src/components/RuntimePrompt.tsx b/frontend/src/components/RuntimePrompt.tsx new file mode 100644 index 00000000..eaf8d89e --- /dev/null +++ b/frontend/src/components/RuntimePrompt.tsx @@ -0,0 +1,82 @@ +import { Download, RefreshCw, TriangleAlert } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { api, describeError } from "../backend/api"; +import { useI18n } from "../i18n"; +import type { AgentStatus, RuntimeStatus } from "../types/api"; + +interface RuntimePromptProps { + runtimes: RuntimeStatus[]; + /** Agent id -> runtime id, as reported by capabilities.missingRuntime. */ + missingRuntime: Record; + selectedAgentIds: string[]; + agents: Record; + onInstalled: () => void | Promise; +} + +/** + * Prompts for the runtimes the current selection needs. Activation installs a + * missing runtime on its own, so this is an early, explicit offer rather than a + * gate: nothing here blocks continuing. + */ +export function RuntimePrompt({ runtimes, missingRuntime, selectedAgentIds, agents, onInstalled }: RuntimePromptProps) { + const { t } = useI18n(); + const [pending, setPending] = useState(""); + const [failure, setFailure] = useState(""); + + const required = useMemo(() => { + const byId = new Map(runtimes.map((runtime) => [runtime.id, runtime])); + const needed = new Map(); + for (const agentId of selectedAgentIds) { + // An Agent that is already installed does not need its package manager. + if (agents[agentId]?.installed) continue; + const runtimeId = missingRuntime[agentId]; + if (!runtimeId) continue; + const runtime = byId.get(runtimeId); + if (runtime && !runtime.installed && runtime.supported) needed.set(runtimeId, runtime); + } + return [...needed.values()]; + }, [agents, missingRuntime, runtimes, selectedAgentIds]); + + if (!required.length) return null; + + const install = async (runtimeId: string) => { + setPending(runtimeId); + setFailure(""); + try { + await api.installRuntime(runtimeId); + await onInstalled(); + } catch (error) { + setFailure(describeError(error, t("运行时安装失败")).message); + } finally { + setPending(""); + } + }; + + return ( +
+
+ ); +} diff --git a/frontend/src/components/RuntimeSection.test.tsx b/frontend/src/components/RuntimeSection.test.tsx new file mode 100644 index 00000000..e6343738 --- /dev/null +++ b/frontend/src/components/RuntimeSection.test.tsx @@ -0,0 +1,136 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { OneAgentApiError } from "../backend/errors"; +import type { RuntimeStatus } from "../types/api"; +import { RuntimePrompt } from "./RuntimePrompt"; +import { RuntimeSection } from "./RuntimeSection"; + +const installRuntime = vi.fn(); + +vi.mock("../backend/api", async () => { + const errors = await import("../backend/errors"); + return { + api: { installRuntime: (runtime: string) => installRuntime(runtime) }, + describeError: errors.describeError, + }; +}); + +function runtime(overrides: Partial = {}): RuntimeStatus { + return { + id: "node", + name: "Node.js", + command: "npm", + installed: false, + version: "", + lockedVersion: "24.18.1", + managed: false, + supported: true, + note: "", + license: "MIT", + licenseUrl: "https://example.test/license", + source: "https://nodejs.org/dist/", + installPath: "/home/user/.oneagent/runtimes/node/v24.18.1", + requiredByHint: "Codex, Claude Code", + ...overrides, + }; +} + +describe("RuntimeSection", () => { + beforeEach(() => { + installRuntime.mockReset(); + }); + + it("reports installed runtimes with their version and offers no install button", () => { + render(); + expect(screen.getByText("Node.js")).toBeTruthy(); + expect(screen.getByText("版本 24.18.1")).toBeTruthy(); + expect(screen.getByText("已安装")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "安装" })).toBeNull(); + }); + + it("installs a missing runtime and refreshes status afterwards", async () => { + installRuntime.mockResolvedValue({ runtime: "node", installed: true, version: "24.18.1", pathUpdated: true, runtimes: [] }); + const onInstalled = vi.fn(); + render(); + expect(screen.getByText("未安装")).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "安装" })); + await waitFor(() => expect(onInstalled).toHaveBeenCalledTimes(1)); + expect(installRuntime).toHaveBeenCalledWith("node"); + }); + + it("surfaces an install failure without refreshing status", async () => { + installRuntime.mockImplementation(async () => { + throw new OneAgentApiError("校验和不匹配", "AGENT_INSTALL_FAILED", true, 400); + }); + const onInstalled = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "安装" })); + await waitFor(() => expect(screen.getByText("校验和不匹配")).toBeTruthy()); + expect(onInstalled).not.toHaveBeenCalled(); + }); + + it("hides runtimes with no locked download for this platform", () => { + render(); + expect(screen.queryByText("Node.js")).toBeNull(); + }); +}); + +describe("RuntimePrompt", () => { + beforeEach(() => { + installRuntime.mockReset(); + }); + + const agents = { + codex: { installed: false } as never, + opencode: { installed: true } as never, + }; + + it("prompts for the runtime a selected, not-yet-installed Agent needs", async () => { + installRuntime.mockResolvedValue({ runtime: "node", installed: true, version: "24.18.1", pathUpdated: true, runtimes: [] }); + const onInstalled = vi.fn(); + render( + , + ); + expect(screen.getByText("需要先安装运行时")).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "安装 Node.js 24.18.1" })); + await waitFor(() => expect(onInstalled).toHaveBeenCalledTimes(1)); + expect(installRuntime).toHaveBeenCalledWith("node"); + }); + + it("stays hidden when the only selected Agent is already installed", () => { + render( + , + ); + expect(screen.queryByText("需要先安装运行时")).toBeNull(); + }); + + it("stays hidden once the runtime is present", () => { + render( + , + ); + expect(screen.queryByText("需要先安装运行时")).toBeNull(); + }); +}); diff --git a/frontend/src/components/RuntimeSection.tsx b/frontend/src/components/RuntimeSection.tsx new file mode 100644 index 00000000..5c8e436b --- /dev/null +++ b/frontend/src/components/RuntimeSection.tsx @@ -0,0 +1,100 @@ +import { Boxes, Download, RefreshCw } from "lucide-react"; +import { useState } from "react"; + +import { api, describeError } from "../backend/api"; +import { useI18n } from "../i18n"; +import type { RuntimeStatus } from "../types/api"; +import { StatusBadge } from "./StatusBadge"; + +interface RuntimeSectionProps { + runtimes: RuntimeStatus[]; + /** Called after a successful install so the caller can refresh status. */ + onInstalled: () => void | Promise; +} + +export function RuntimeSection({ runtimes, onInstalled }: RuntimeSectionProps) { + const { t } = useI18n(); + const [pending, setPending] = useState(""); + const [failure, setFailure] = useState(""); + + const supported = runtimes.filter((runtime) => runtime.supported || runtime.installed); + if (!supported.length) return null; + const missing = supported.filter((runtime) => !runtime.installed); + + const install = async (runtimeId: string) => { + setPending(runtimeId); + setFailure(""); + try { + await api.installRuntime(runtimeId); + await onInstalled(); + } catch (error) { + setFailure(describeError(error, t("运行时安装失败")).message); + } finally { + setPending(""); + } + }; + + return ( +
+
+
+

{t("运行时")}

+

+ {missing.length + ? t("缺少 {count} 个运行时,安装后即可自动安装对应 Agent。", { count: missing.length }) + : t("Agent 安装所需的运行时都已就绪。")} +

+
+
+ {failure ?
{failure}
: null} +
+ {supported.map((runtime) => ( +
+ + {runtime.name} + + {runtime.installed + ? runtime.version + ? t("版本 {version}", { version: runtime.version }) + : t("版本未知") + : runtime.requiredByHint + ? t("{agents} 需要", { agents: runtime.requiredByHint }) + : t("待安装")} + + + + {t("锁定版本")} + {runtime.lockedVersion} + + + {t("来源")} + {runtime.managed ? t("由 OneAgent 安装") : runtime.installed ? t("本机已有") : runtime.source} + + + {runtime.installed ? t("已安装") : t("未安装")} + + {runtime.installed ? ( +
+ ))} +
+ {missing.length ? ( +

+ {t("运行时会安装到 ~/.oneagent/runtimes,并写入登录 PATH,不需要管理员权限。")} +

+ ) : null} +
+ ); +} diff --git a/frontend/src/components/SecureKeyField.test.tsx b/frontend/src/components/SecureKeyField.test.tsx index 364e4220..f79fd86a 100644 --- a/frontend/src/components/SecureKeyField.test.tsx +++ b/frontend/src/components/SecureKeyField.test.tsx @@ -25,4 +25,10 @@ describe("SecureKeyField", () => { await userEvent.click(screen.getByRole("button", { name: "隐藏密钥" })); expect(input).toHaveAttribute("type", "password"); }); + + it("shows a key loaded after the field opens", () => { + const page = render( {}} />); + page.rerender( {}} />); + expect(screen.getByLabelText("API Key")).toHaveValue("sk-persisted"); + }); }); diff --git a/frontend/src/components/SecureKeyField.tsx b/frontend/src/components/SecureKeyField.tsx index 3c14bdf8..5d589021 100644 --- a/frontend/src/components/SecureKeyField.tsx +++ b/frontend/src/components/SecureKeyField.tsx @@ -1,11 +1,15 @@ import { Eye, EyeOff, KeyRound } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; + +import { useI18n } from "../i18n"; export function SecureKeyField({ value, onChange }: { value: string; onChange: (value: string) => void }) { + const { t } = useI18n(); const [visible, setVisible] = useState(false); // The wizard keeps the key in a ref, not in state, so the parent gives no // re-render guarantee per keystroke; echo must come from local state. const [draft, setDraft] = useState(value); + useEffect(() => setDraft(value), [value]); return (
@@ -21,13 +25,13 @@ export function SecureKeyField({ value, onChange }: { value: string; onChange: ( }} autoComplete="off" spellCheck={false} - placeholder="粘贴你的 API Key" + placeholder={t("粘贴你的 API Key")} /> -
- 密钥只发送到当前本机服务,并写入确认页列出的本地配置。 + {t("密钥只发送到当前本机服务,并保存在本机私有配置中。")} ); } diff --git a/frontend/src/components/SetupStepper.tsx b/frontend/src/components/SetupStepper.tsx index 2e404804..347c1033 100644 --- a/frontend/src/components/SetupStepper.tsx +++ b/frontend/src/components/SetupStepper.tsx @@ -1,12 +1,13 @@ import { Check } from "lucide-react"; import { useLocation } from "react-router-dom"; +import { type TranslationKey, useI18n } from "../i18n"; import { useWizard } from "../state/WizardContext"; // Single source of truth for the setup sequence: order, labels, and which // steps the existing-account mode skips. Pages no longer pass step numbers; // the current step is derived from the route. -const steps = [ +const steps: Array<{ path: string; label: TranslationKey | "Agent" | "Provider"; skippedForExistingAccount: boolean }> = [ { path: "/setup/agents", label: "Agent", skippedForExistingAccount: false }, { path: "/setup/mode", label: "配置", skippedForExistingAccount: false }, { path: "/setup/provider", label: "Provider", skippedForExistingAccount: true }, @@ -15,13 +16,14 @@ const steps = [ ]; export function SetupStepper() { + const { t } = useI18n(); const { pathname } = useLocation(); const { state } = useWizard(); const skipsProvider = state.configMode === "existing-account"; const current = steps.findIndex((step) => step.path === pathname) + 1; return ( -
    +
      {steps.map((step, index) => { const number = index + 1; const skipped = skipsProvider && step.skippedForExistingAccount; @@ -34,7 +36,7 @@ export function SetupStepper() { aria-current={active ? "step" : undefined} > {complete ? : number} - {skipped ? "已跳过" : step.label} + {skipped ? t("已跳过") : step.label === "Agent" || step.label === "Provider" ? step.label : t(step.label)} ); })} diff --git a/frontend/src/components/icons/agents.tsx b/frontend/src/components/icons/agents.tsx index 35bdf864..2f3f4e3e 100644 --- a/frontend/src/components/icons/agents.tsx +++ b/frontend/src/components/icons/agents.tsx @@ -17,6 +17,8 @@ */ import { Bot } from "lucide-react"; +import { sourceTranslate, type Translate, type TranslationKey } from "../../i18n"; + import aiderMark from "./assets/aider.png"; import claudeMark from "./assets/claude-code.svg"; import codexMark from "./assets/codex.svg"; @@ -39,7 +41,7 @@ const MARKS: Record = { }; /** One-line positioning shown on hover; never a restatement of the name. */ -const TAGLINES: Record = { +const TAGLINES: Record = { codex: "OpenAI 的终端编码代理", "claude-code": "Anthropic 的终端编码代理", opencode: "开源终端编码代理", @@ -52,8 +54,9 @@ const TAGLINES: Record = { export const AGENT_ICON_IDS = Object.keys(MARKS); -export function agentTagline(agentId: string): string { - return TAGLINES[agentId] ?? ""; +export function agentTagline(agentId: string, t: Translate = sourceTranslate): string { + const tagline = TAGLINES[agentId]; + return tagline ? t(tagline) : ""; } /** The provenance of an Agent's mark, for the reference notes and tests. */ diff --git a/frontend/src/i18n.test.tsx b/frontend/src/i18n.test.tsx new file mode 100644 index 00000000..7beebeb2 --- /dev/null +++ b/frontend/src/i18n.test.tsx @@ -0,0 +1,21 @@ +import { act, renderHook } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider, LOCALE_STORAGE_KEY, translate, useI18n } from "./i18n"; + +describe("i18n", () => { + it("translates placeholders and persists language changes", () => { + expect(translate("en", "已选择 {count} 个 Agent", { count: 2 })).toBe("Selected: 2"); + localStorage.setItem(LOCALE_STORAGE_KEY, "en"); + const wrapper = ({ children }: PropsWithChildren) => {children}; + const { result } = renderHook(() => useI18n(), { wrapper }); + + expect(result.current.t("返回")).toBe("Back"); + expect(document.documentElement.lang).toBe("en"); + act(() => result.current.setLocale("zh-CN")); + expect(result.current.t("返回")).toBe("返回"); + expect(document.documentElement.lang).toBe("zh-CN"); + expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe("zh-CN"); + }); +}); diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx new file mode 100644 index 00000000..ee01c8ba --- /dev/null +++ b/frontend/src/i18n.tsx @@ -0,0 +1,316 @@ +import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; + +const english = { + "Agent 管家": "Agent Manager", + "主导航": "Main navigation", + "工作区": "Workspace", + "激活环境": "Environment", + "配置模板": "Profiles", + "语言": "Language", + "返回": "Back", + "返回总览": "Back to overview", + "继续": "Continue", + "保存": "Save", + "保存中": "Saving", + "取消": "Cancel", + "关闭编辑": "Close editor", + "编辑": "Edit", + "删除": "Delete", + "名称": "Name", + "模型": "Model", + "版本": "Version", + "未知": "Unknown", + "暂无": "None", + "未记录": "Not recorded", + "未绑定": "Not linked", + "未安装": "Not installed", + "已安装": "Installed", + "待安装": "Not installed", + "仅引导": "Guide only", + "重试": "Retry", + "已完成": "Completed", + "失败": "Failed", + "正在处理": "Processing", + "等待执行": "Waiting", + "高级选项": "Advanced options", + "模型服务": "Provider", + "新增 Provider": "Add provider", + "搜索模型": "Search models", + "模型列表": "Model list", + "没有匹配的模型": "No matching models", + "或手动输入模型 ID": "Or enter a model ID", + "手动输入模型 ID": "Enter a model ID", + "例如 gpt-4.1": "For example, gpt-4.1", + "例如 deepseek/deepseek-v3": "For example, deepseek/deepseek-v3", + "例如 siliconflow": "For example, siliconflow", + "例如 team-ppio": "For example, team-ppio", + "例如 团队 PPIO": "For example, Team PPIO", + "粘贴你的 API Key": "Paste your API key", + "隐藏密钥": "Hide API key", + "显示密钥": "Show API key", + "密钥只发送到当前本机服务,并保存在本机私有配置中。": "The key is sent only to the local service and stored in private local configuration.", + "激活步骤": "Setup steps", + "配置": "Configure", + "确认": "Review", + "已跳过": "Skipped", + "尚未测试连接": "Connection not tested", + "正在验证端点和 Key": "Validating endpoint and key", + "连接失败": "Connection failed", + "查看安装日志": "View installation log", + "按官方方式安装与登录": "Install and sign in using the official method", + "已检测到": "Detected", + "官方安装": "Official install", + "官方文档": "Official docs", + "显示官方安装与配置步骤": "Show official installation and setup steps", + "支持检测、安装与初始化配置": "Supports detection, installation, and initial setup", + "选择 {name}": "Select {name}", + "配置无法解析": "Configuration could not be parsed", + "配置文件当前指向 {url}": "Configuration file currently points to {url}", + "检测到的配置,非 OneAgent 写入": "Detected configuration not written by OneAgent", + "未配置": "Not configured", + "{version}(锁定 {lockedVersion})": "{version} (locked to {lockedVersion})", + "选择 Agent": "Select agents", + "选择要检测、安装或配置的开发工具,可以同时处理多个。": "Choose the development tools to detect, install, or configure. You can process several at once.", + "已选择 {count} 个 Agent": "Selected: {count}", + "至少选择一个 Agent": "Select at least one agent", + "正在检测本机环境": "Detecting the local environment", + "常用 Agent": "Popular agents", + "可一键配置的使用锁定版本完成初始化,仅引导的只显示官方步骤。": "One-click agents use locked versions for setup; guide-only agents show official steps.", + "更多 Agent({count})": "More agents ({count})", + "网关、平台账号与 IDE 扩展": "Gateways, platform accounts, and IDE extensions", + "安装缺失的 Agent": "Install missing agents", + "仅调用 lock manifest 中允许的官方 npm 或 uv 包。": "Uses only official npm or uv packages allowed by the lock manifest.", + "配置方式": "Setup method", + "选择由 OneAgent 初始化模型服务,或保留已有官方账号与本机配置。": "Let OneAgent configure a model provider, or keep existing accounts and local settings.", + "Provider 与模型步骤将标记为已跳过": "Provider and model steps will be marked as skipped", + "配置模型服务": "Configure a model provider", + "选择 PPIO、Novita 或自定义 OpenAI-compatible 服务。": "Choose PPIO, Novita, or a custom OpenAI-compatible service.", + "OneAgent 将测试连接、发现模型并写入本机配置。": "OneAgent will test the connection, discover models, and write local configuration.", + "使用已有账号或配置": "Use an existing account or configuration", + "只检测或安装 Agent,不写入第三方模型服务配置。": "Only detect or install agents without writing third-party provider settings.", + "适合已登录官方账号,或已经维护本机配置的用户。": "For users already signed in to official accounts or maintaining local configuration.", + "跳过配置不会删除或覆盖现有 Agent 设置。": "Skipping setup will not delete or overwrite existing agent settings.", + "无法读取已保存的 API Key": "Could not read the saved API key", + "连接测试失败": "Connection test failed", + "无法打开注册页面": "Could not open the registration page", + "连接模型服务": "Connect a model provider", + "Key 不会进入日志、URL 或前端持久化状态。": "The key is never written to logs, URLs, or persistent frontend state.", + "继续选择模型": "Continue to model selection", + "注册并获取 Key": "Register and get a key", + "自定义模型名称(可选)": "Custom model name (optional)", + "填写后将用此模型测试连接;留空时自动选择。": "When provided, this model is used for the connection test. Leave blank to select automatically.", + "测试连接": "Test connection", + "连接测试通过后才能继续选择模型。": "Pass the connection test before selecting a model.", + "无法获取模型列表": "Could not load the model list", + "选择模型": "Select a model", + "从当前 Key 可访问的模型中选择,接口不支持时可直接输入模型 ID。": "Choose a model accessible with this key, or enter a model ID when discovery is unavailable.", + "刷新列表": "Refresh list", + "正在读取模型列表": "Loading models", + "确认激活": "Review activation", + "核对安装、配置和备份范围。API Key 不会显示在此页。": "Review the installation, configuration, and backup scope. The API key is not shown here.", + "开始激活": "Start activation", + "覆盖前会自动创建时间戳备份": "A timestamped backup is created before overwrite", + "将处理": "Agents", + "检测并配置": "Detect and configure", + "安装并配置": "Install and configure", + "显示引导": "Show guide", + "只写配置": "Configure only", + "已有账号 / 本机配置": "Existing account / local configuration", + "本地写入": "Local changes", + "由 Agent 官方配置合约决定": "Defined by the agent's official configuration contract", + "模型配置": "Model configuration", + "跳过,不覆盖已有设置": "Skipped; existing settings are preserved", + "环境摘要": "Environment summary", + "仅引导项目": "Guide-only items", + "{count} 个,不写私有配置": "{count}; no private configuration written", + "激活失败": "Activation failed", + "重试失败": "Retry failed", + "正在激活": "Activating", + "激活完成": "Activation complete", + "需要处理部分问题": "Some items need attention", + "安装请求同步执行,完成后将显示每个 Agent 的最终状态。": "Installation runs synchronously. Each agent's final status appears when it completes.", + "每个 Agent 的结果彼此独立,失败项可以单独重试。": "Each agent has an independent result. Failed items can be retried individually.", + "进入总览": "Open overview", + "请保持此窗口打开": "Keep this window open", + "下一步命令": "Next command", + "环境总览": "Environment overview", + "正在读取环境状态": "Loading environment status", + "本机已安装 Agent 及其当前配置。": "Installed agents and their current local configuration.", + "无法读取环境状态": "Could not load environment status", + "请刷新后重试。": "Refresh and try again.", + "本机已安装 Agent 及其当前 Provider、Profile 与模型。": "Installed agents and their current providers, profiles, and models.", + "刷新状态": "Refresh status", + "已安装 Agent": "Installed agents", + "共 {count} 个": "Total: {count}", + "尚未安装任何 Agent": "No agents installed", + "运行时": "Runtimes", + "缺少 {count} 个运行时,安装后即可自动安装对应 Agent。": "{count} runtime(s) missing. Install them to enable automatic agent installation.", + "Agent 安装所需的运行时都已就绪。": "Every runtime needed to install agents is ready.", + "运行时安装失败": "Could not install the runtime", + "版本 {version}": "Version {version}", + "版本未知": "Version unknown", + "{agents} 需要": "Required by {agents}", + "锁定版本": "Locked version", + "来源": "Source", + "由 OneAgent 安装": "Installed by OneAgent", + "本机已有": "Already on this machine", + "安装": "Install", + "安装中": "Installing", + "运行时会安装到 ~/.oneagent/runtimes,并写入登录 PATH,不需要管理员权限。": "Runtimes install into ~/.oneagent/runtimes and are added to your login PATH. No administrator rights needed.", + "需要先安装运行时": "A runtime is needed first", + "所选 Agent 通过 {runtimes} 安装,本机还没有。现在安装,或在激活时自动安装。": "The selected agents install through {runtimes}, which is not on this machine yet. Install it now, or let activation install it.", + "安装 {name} {version}": "Install {name} {version}", + "在配置模板中创建 Profile 并应用后,已安装的 Agent 会显示在这里。": "Create and apply a profile to see installed agents here.", + "找不到可配置的 Agent": "Configurable agent not found", + "{id} 不在可一键配置的范围内。": "{id} is not available for one-click setup.", + "未指定 Agent。": "No agent specified.", + "应用配置失败": "Could not apply configuration", + "应用中": "Applying", + "应用": "Apply", + "当前指向": "Current target", + "配置文件": "Configuration file", + "备份": "Backups", + "已有历史备份": "Previous backup available", + "这个 Agent 已有配置,不是 OneAgent 写入的": "This agent has configuration not written by OneAgent", + "未知端点": "Unknown endpoint", + "当前指向 {target}。应用后会被替换,原文件会先备份到同目录的": "Currently points to {target}. Applying will replace it; the original file will first be backed up in the same directory as", + "时间戳": "timestamp", + "将测试 {protocol} 协议": "Testing the {protocol} protocol", + "可以指定具体模型。留空时由端点的模型列表自动选择,多数情况保持默认即可。": "Optionally choose a specific model. Leave blank for endpoint discovery; the default works in most cases.", + "留空则由端点的模型列表自动选择": "Leave blank to select from the endpoint's model list", + "快速小模型": "Fast small model", + "留空则与主模型相同": "Leave blank to use the primary model", + "已写入配置": "Configuration written", + "无法读取 Provider": "Could not load provider", + "无法保存 Provider": "Could not save provider", + "删除 Provider“{name}”?": "Delete provider \"{name}\"?", + "无法删除 Provider": "Could not delete provider", + "管理模型服务、端点与本机保存的 API Key。": "Manage model providers, endpoints, and locally saved API keys.", + "编辑 {name}": "Edit {name}", + "用户添加": "User added", + "官网": "Website", + "官网(可选)": "Website (optional)", + "OpenAI 兼容 Base URL": "OpenAI-compatible base URL", + "Anthropic 兼容 Base URL(可选)": "Anthropic-compatible base URL (optional)", + "OpenAI 兼容": "OpenAI-compatible", + "Anthropic 兼容": "Anthropic-compatible", + "删除 {name}": "Delete {name}", + "暂无 Agent 使用": "Not used by any agent", + "已保存 Key": "Key saved", + "未保存 Key": "No saved key", + "用户 Provider 的协议兼容性由你自己保证,OneAgent 不会为它降级或改写请求。": "You are responsible for custom provider protocol compatibility. OneAgent does not downgrade or rewrite requests.", + "无法保存 Profile": "Could not save profile", + "应用 Profile 失败": "Could not apply profile", + "{name} 已应用到 {count} 个 Agent": "Applied {name} to {count} agents", + "无法应用 Profile": "Could not apply profile", + "在这里创建 Profile,再将它应用到所选 Agent。": "Create profiles here, then apply them to selected agents.", + "新增 Profile": "Add profile", + "留空将保留这个 Profile 已保存的 Key。": "Leave blank to keep this profile's saved key.", + "留空将使用 Provider 已保存的 Key。": "Leave blank to use the provider's saved key.", + "适用 Agent": "Agents", + "保存 Profile": "Save profile", + "应用完成": "Applied", + "还没有 Profile": "No profiles yet", + "新建一个 Profile,保存 Provider、模型、Key 和适用 Agent。": "Create a profile to save a provider, model, key, and agents.", + "已保存密钥": "Key saved", + "未保存密钥": "No saved key", + "未指定模型": "No model specified", + "未选择 Agent": "No agents selected", + "适用:{agents}": "Agents: {agents}", + "安装缺失的 Agent 并应用此 Profile": "Install missing agents and apply this profile", + "请先补全模型、Agent 和 Key": "Add a model, agents, and key first", + "应用到 Agent": "Apply to agents", + "无法读取本机状态": "Could not read local status", + "OneAgent 请求失败": "OneAgent request failed", + "无法调用本机 OneAgent 服务": "Could not call the local OneAgent service", + "OpenAI 的终端编码代理": "OpenAI's terminal coding agent", + "Anthropic 的终端编码代理": "Anthropic's terminal coding agent", + "开源终端编码代理": "Open-source terminal coding agent", + "多模型编排的命令行代理": "Command-line agent with multi-model orchestration", + "结对编程式的仓库编辑代理": "Pair-programming repository editing agent", + "AI 编辑器,按官方方式安装": "AI editor installed through the official channel", + "多渠道 AI 网关,常驻运行": "Persistent multi-channel AI gateway", + "自我成长型 Agent 框架": "Self-improving agent framework", +} as const; + +export type Locale = "zh-CN" | "en"; +export type TranslationKey = keyof typeof english; +export type TranslationValues = Record; +export type Translate = (key: TranslationKey, values?: TranslationValues) => string; + +export const LOCALE_STORAGE_KEY = "oneagent.locale"; + +function interpolate(template: string, values: TranslationValues = {}): string { + return template.replace(/\{(\w+)\}/g, (placeholder, name: string) => + Object.hasOwn(values, name) ? String(values[name]) : placeholder, + ); +} + +export function translate(locale: Locale, key: TranslationKey, values?: TranslationValues): string { + return interpolate(locale === "en" ? english[key] : key, values); +} + +export const sourceTranslate: Translate = (key, values) => translate("zh-CN", key, values); + +function preferredLocale(): Locale { + try { + const saved = localStorage.getItem(LOCALE_STORAGE_KEY); + if (saved === "en" || saved === "zh-CN") return saved; + } catch { + // Storage can be unavailable in hardened webviews; system language still works. + } + return typeof navigator !== "undefined" && !navigator.language.toLowerCase().startsWith("zh") ? "en" : "zh-CN"; +} + +let activeLocale: Locale | undefined; + +export function currentLocale(): Locale { + try { + const saved = localStorage.getItem(LOCALE_STORAGE_KEY); + if (saved === "en" || saved === "zh-CN") return saved; + } catch { + // Fall through to the active provider or system language. + } + return activeLocale ?? preferredLocale(); +} + +interface I18nContextValue { + locale: Locale; + setLocale: (locale: Locale) => void; + t: Translate; +} + +const fallback: I18nContextValue = { + locale: "zh-CN", + setLocale: () => undefined, + t: sourceTranslate, +}; + +const I18nContext = createContext(fallback); + +export function I18nProvider({ children }: PropsWithChildren) { + const [locale, setLocaleState] = useState(preferredLocale); + const localeRef = useRef(locale); + localeRef.current = locale; + activeLocale = locale; + const setLocale = useCallback((next: Locale) => { + setLocaleState(next); + try { + localStorage.setItem(LOCALE_STORAGE_KEY, next); + } catch { + // The in-memory choice remains active when persistence is unavailable. + } + }, []); + const t = useCallback((key, values) => translate(localeRef.current, key, values), []); + + useEffect(() => { + document.documentElement.lang = locale; + }, [locale]); + + const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]); + return {children}; +} + +export function useI18n(): I18nContextValue { + return useContext(I18nContext); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 93404e46..f74ca0c2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,7 +6,6 @@ import App from "./App"; import "./styles/tokens.css"; import "./styles/base.css"; import "./styles/app.css"; -import "./styles/landing.css"; createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/pages/ActivationPage.tsx b/frontend/src/pages/ActivationPage.tsx index d2336b6b..3dd43135 100644 --- a/frontend/src/pages/ActivationPage.tsx +++ b/frontend/src/pages/ActivationPage.tsx @@ -1,15 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { api, describeError } from "../api/client"; +import { api, describeError } from "../backend/api"; import { AgentProgressRow } from "../components/AgentProgressRow"; import { LogDisclosure } from "../components/LogDisclosure"; import { PageScaffold } from "../components/PageScaffold"; +import { useI18n } from "../i18n"; import { useWizard } from "../state/WizardContext"; import type { InstallRequest } from "../types/api"; export function ActivationPage() { const navigate = useNavigate(); + const { t } = useI18n(); const { state, dispatch, secret, refreshStatus } = useWizard(); const started = useRef(false); const [retrying, setRetrying] = useState(null); @@ -23,7 +25,7 @@ export function ActivationPage() { agents, profile_agents: profileAgents, provider: state.provider, - api_base_url: state.provider === "custom" ? state.customBaseUrl : "", + api_base_url: "", api_key: state.configMode === "provider" ? secret.keyRef.current : "", // SetupGuard guarantees a model in provider mode; existing-account mode // sends none and the backend does not write model config for it. @@ -33,7 +35,7 @@ export function ActivationPage() { skip_test: state.configMode !== "provider", locked_version: true, }), - [secret.keyRef, state.configMode, state.customBaseUrl, state.installMissingAgents, state.model, state.provider], + [secret.keyRef, state.configMode, state.installMissingAgents, state.model, state.provider], ); const activate = useCallback(async () => { @@ -53,9 +55,11 @@ export function ActivationPage() { await refreshStatus(); } } catch (error) { - dispatch({ type: "ACTIVATION_FAILED", message: describeError(error, "激活失败").message }); + dispatch({ type: "ACTIVATION_FAILED", message: describeError(error, t("激活失败")).message }); } - }, [dispatch, refreshStatus, requestFor, secret, state.selectedAgentIds]); + }, [dispatch, refreshStatus, requestFor, secret, state.selectedAgentIds, t]); + + useEffect(() => api.onInstallOutput((output) => dispatch({ type: "ACTIVATION_OUTPUT", output })), [dispatch]); useEffect(() => { // Runs only for an explicit request from the review page. Returning here @@ -85,7 +89,7 @@ export function ActivationPage() { await refreshStatus(); } } catch (error) { - dispatch({ type: "ACTIVATION_FAILED", message: describeError(error, "重试失败").message }); + dispatch({ type: "ACTIVATION_FAILED", message: describeError(error, t("重试失败")).message }); } finally { setRetrying(null); } @@ -94,12 +98,12 @@ export function ActivationPage() { const allDone = state.activationState === "success"; return ( navigate("/setup/review")} - primaryLabel={allDone ? "进入总览" : undefined} + primaryLabel={allDone ? t("进入总览") : undefined} onPrimary={allDone ? () => navigate("/overview") : undefined} - footerNote={state.activationState === "loading" ? "请保持此窗口打开" : undefined} + footerNote={state.activationState === "loading" ? t("请保持此窗口打开") : undefined} >
      {state.selectedAgentIds.map((agentId) => { @@ -122,11 +126,11 @@ export function ActivationPage() { the overview a user opens every day. */} {allDone && state.activationNext ? (
      -

      下一步命令

      +

      {t("下一步命令")}

      {state.activationNext}
      ) : null} - + ); } diff --git a/frontend/src/pages/AgentDetailPage.test.tsx b/frontend/src/pages/AgentDetailPage.test.tsx index 9dcc14e0..ab7d65d5 100644 --- a/frontend/src/pages/AgentDetailPage.test.tsx +++ b/frontend/src/pages/AgentDetailPage.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; +import { api } from "../backend/api"; import type { StatusResponse } from "../types/api"; import { AgentDetailPage } from "./AgentDetailPage"; @@ -15,7 +16,8 @@ function status(): StatusResponse { return { apiVersion: 1, platform: { os: "macos", arch: "arm64", shell: "bash" }, - capabilities: { canInstall: { codex: true }, supportedAgentIds: ["codex"] }, + runtimes: [], + capabilities: { canInstall: { codex: true }, missingRuntime: {}, supportedAgentIds: ["codex"] }, agents: { codex: { installed: true, @@ -26,6 +28,7 @@ function status(): StatusResponse { lockedVersion: "0.145.0", canInstall: true, provider: "ppio", + profileId: null, model: "deepseek/deepseek-v3", baseUrl: "https://api.ppio.com/openai", updatedAt: "2026-07-27T00:00:00Z", @@ -62,7 +65,8 @@ function claudeStatus(): StatusResponse { const base = status(); return { ...base, - capabilities: { canInstall: { "claude-code": true }, supportedAgentIds: ["claude-code"] }, + runtimes: [], + capabilities: { canInstall: { "claude-code": true }, missingRuntime: {}, supportedAgentIds: ["claude-code"] }, agents: { "claude-code": { installed: true, @@ -73,6 +77,7 @@ function claudeStatus(): StatusResponse { lockedVersion: "2.1.217", canInstall: true, provider: "ppio", + profileId: null, model: "model-a", baseUrl: "https://api.ppio.com/anthropic", updatedAt: "2026-07-27T00:00:00Z", @@ -104,6 +109,7 @@ function renderPage(agentId = "codex", override?: StatusResponse) { } /> + 新增页占位
      } /> 总览占位} /> , @@ -129,9 +135,22 @@ describe("AgentDetailPage", () => { expect(screen.getByRole("button", { name: /^应用/ }).hasAttribute("disabled")).toBe(true); }); + it("offers user Providers in the configuration menu", () => { + const withUserProvider = status(); + withUserProvider.providers.acme = { name: "Acme", home: "", base_url: "https://api.acme.test", custom: true }; + renderPage("codex", withUserProvider); + expect(screen.getByRole("option", { name: "Acme" })).toBeTruthy(); + expect(screen.queryByRole("option", { name: "自定义端点" })).toBeNull(); + }); + + it("opens the Provider creation page from the picker", () => { + renderPage(); + fireEvent.click(screen.getByRole("button", { name: "新增 Provider" })); + expect(screen.getByText("新增页占位")).toBeTruthy(); + }); + it("drops a passing verdict when the key is edited afterwards", async () => { // Constraint 2: otherwise a wrong key rides in on the previous verdict. - const { api } = await import("../api/client"); vi.spyOn(api, "probe").mockResolvedValue(passingProbe()); renderPage(); fireEvent.change(screen.getByLabelText(/API Key/i), { target: { value: "sk-good" } }); @@ -147,7 +166,6 @@ describe("AgentDetailPage", () => { it("reports the restart instruction and clears the key after applying", async () => { // Constraints 3 and 4: an Agent reads its config at startup, so silence // reads as failure; and a key left in a visible field outlives its request. - const { api } = await import("../api/client"); vi.spyOn(api, "probe").mockResolvedValue(passingProbe()); vi.spyOn(api, "activateAgent").mockResolvedValue({ ok: true, @@ -201,7 +219,6 @@ describe("AgentDetailPage", () => { it("offers Claude Code a fast small-model field and sends it on activate", async () => { // The one user-facing difference between adapters: Claude Code runs its // background work on a second, optionally cheaper model. - const { api } = await import("../api/client"); vi.spyOn(api, "probe").mockResolvedValue(passingProbe()); const activate = vi.spyOn(api, "activateAgent").mockResolvedValue({ ok: true, @@ -267,4 +284,15 @@ describe("AgentDetailPage", () => { renderPage("codex", ours); expect(screen.queryByText(/不是 OneAgent 写入/)).toBeNull(); }); + + it("shows the Provider key again when the page is reopened", async () => { + const saved = status(); + saved.providers.ppio.has_key = true; + vi.spyOn(api, "getProvider").mockResolvedValue({ + id: "ppio", name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai", + anthropic_base_url: "https://api.ppio.com/anthropic", api_key: "sk-persisted", built_in: true, + }); + renderPage("codex", saved); + await waitFor(() => expect(screen.getByLabelText(/API Key/i)).toHaveValue("sk-persisted")); + }); }); diff --git a/frontend/src/pages/AgentDetailPage.tsx b/frontend/src/pages/AgentDetailPage.tsx index 0c4d4abe..a17a8dc8 100644 --- a/frontend/src/pages/AgentDetailPage.tsx +++ b/frontend/src/pages/AgentDetailPage.tsx @@ -1,8 +1,8 @@ import { FlaskConical } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { api, describeError } from "../api/client"; +import { api, describeError } from "../backend/api"; import { AdvancedSection } from "../components/AdvancedSection"; import { ConnectionStatus } from "../components/ConnectionStatus"; import { AgentIcon, agentTagline } from "../components/icons/agents"; @@ -10,6 +10,7 @@ import { PageScaffold } from "../components/PageScaffold"; import { ProviderSegment } from "../components/ProviderSegment"; import { SecureKeyField } from "../components/SecureKeyField"; import { targetSummary, versionNote } from "../components/AgentManageRow"; +import { useI18n } from "../i18n"; import { useWizard } from "../state/WizardContext"; import { PROTOCOL_LABELS } from "../types/api"; import type { ProbeResponse, ProviderId } from "../types/api"; @@ -18,13 +19,14 @@ export function AgentDetailPage() { const { agentId = "" } = useParams(); const [params] = useSearchParams(); const navigate = useNavigate(); + const { locale, t } = useI18n(); const { state, refreshStatus } = useWizard(); const status = state.status; const agent = status?.agents[agentId]; const catalog = status?.catalog.find((item) => item.id === agentId); - const [provider, setProvider] = useState((agent?.provider as ProviderId) || "ppio"); - const [customBaseUrl, setCustomBaseUrl] = useState(agent?.provider === "custom" ? agent.baseUrl || "" : ""); + const initialProvider = agent?.provider && status?.providers[agent.provider] ? agent.provider : "ppio"; + const [provider, setProvider] = useState(initialProvider); const [apiKey, setApiKey] = useState(""); const [model, setModel] = useState(agent?.model || ""); const [smallFastModel, setSmallFastModel] = useState(""); @@ -37,23 +39,36 @@ export function AgentDetailPage() { // cannot clear the field; remounting it does. const [keyFieldId, setKeyFieldId] = useState(0); + useEffect(() => { + if (!status?.providers[provider]?.has_key) return; + let active = true; + void api.getProvider(provider) + .then((entry) => { + if (active) setApiKey(entry.api_key); + }) + .catch((error) => { + if (active) setFailure(describeError(error, t("无法读取已保存的 API Key")).message); + }); + return () => { active = false; }; + }, [provider, status?.providers]); + if (!status || !agent || !catalog || catalog.configMode !== "auto") { return ( navigate("/overview")} >
      - 找不到可配置的 Agent - {agentId ? `${agentId} 不在可一键配置的范围内。` : "未指定 Agent。"} + {t("找不到可配置的 Agent")} + {agentId ? t("{id} 不在可一键配置的范围内。", { id: agentId }) : t("未指定 Agent。")}
      ); } - const version = versionNote(agent); - const target = targetSummary(agent, status.providers); + const version = versionNote(agent, t); + const target = targetSummary(agent, status.providers, t); // A configuration OneAgent did not write is the case worth warning about: // applying replaces it, and the user may not know it is there. A backup is // taken either way, but saying so beforehand is the point. @@ -61,7 +76,7 @@ export function AgentDetailPage() { agent.detected && !agent.detected.managedByOneAgent && !agent.detected.unreadable ? agent.detected : null; - const canProbe = Boolean(apiKey) && (provider !== "custom" || Boolean(customBaseUrl.trim())); + const canProbe = Boolean(apiKey); const canApply = canProbe && probeState === "success" && !applying; const resetVerdict = () => { @@ -77,7 +92,7 @@ export function AgentDetailPage() { try { const result = await api.probe({ provider, - apiBaseUrl: provider === "custom" ? customBaseUrl : "", + apiBaseUrl: "", apiKey, model, agents: [agentId], @@ -86,7 +101,7 @@ export function AgentDetailPage() { setProbeState(result.ok ? "success" : "error"); } catch (error) { setProbeState("error"); - setFailure(describeError(error, "连接测试失败").message); + setFailure(describeError(error, t("连接测试失败")).message); } }; @@ -96,7 +111,7 @@ export function AgentDetailPage() { try { const result = await api.activateAgent(agentId, { provider, - apiBaseUrl: provider === "custom" ? customBaseUrl : "", + apiBaseUrl: "", apiKey, model, profileId: params.get("profile") || undefined, @@ -109,7 +124,7 @@ export function AgentDetailPage() { setProbe(null); void refreshStatus(); } catch (error) { - setFailure(describeError(error, "应用配置失败").message); + setFailure(describeError(error, t("应用配置失败")).message); } finally { setApplying(false); } @@ -118,10 +133,10 @@ export function AgentDetailPage() { return ( navigate("/overview")} - primaryLabel={applying ? "应用中" : "应用"} + primaryLabel={applying ? t("应用中") : t("应用")} onPrimary={() => void apply()} primaryDisabled={!canApply} > @@ -131,23 +146,23 @@ export function AgentDetailPage() {
      -
      当前指向
      +
      {t("当前指向")}
      {target.text} {target.note ? {target.note} : null}
      -
      版本
      -
      {version?.text || "未安装"}
      +
      {t("版本")}
      +
      {version?.text || t("未安装")}
      -
      配置文件
      +
      {t("配置文件")}
      {agent.config || "—"}
      -
      备份
      -
      {status.backups[agentId] ? "已有历史备份" : "暂无"}
      +
      {t("备份")}
      +
      {status.backups[agentId] ? t("已有历史备份") : t("暂无")}
      @@ -155,38 +170,26 @@ export function AgentDetailPage() {
      {willOverwrite ? (
      - 这个 Agent 已有配置,不是 OneAgent 写入的 + {t("这个 Agent 已有配置,不是 OneAgent 写入的")} - 当前指向 {willOverwrite.baseUrl || "未知端点"} - {willOverwrite.model ? ` · ${willOverwrite.model}` : ""}。应用后会被替换,原文件会先备份到同目录的 + {t("当前指向 {target}。应用后会被替换,原文件会先备份到同目录的", { + target: [willOverwrite.baseUrl || t("未知端点"), willOverwrite.model].filter(Boolean).join(" · "), + })} {" "} - *.backup-<时间戳>。 + *.backup-<{t("时间戳")}>{locale === "en" ? "." : "。"}
      ) : null} navigate(`/providers/new?returnTo=${encodeURIComponent(`/agents/${agentId}`)}`)} onChange={(next) => { setProvider(next); + setApiKey(""); resetVerdict(); }} /> - {provider === "custom" ? ( -
      - - { - setCustomBaseUrl(event.target.value); - resetVerdict(); - }} - placeholder="https://models.example.com/openai" - inputMode="url" - /> -
      - ) : null} - 测试连接 + {t("测试连接")} {catalog.protocol ? ( - 将测试 {PROTOCOL_LABELS[catalog.protocol]} 协议 + {t("将测试 {protocol} 协议", { protocol: PROTOCOL_LABELS[catalog.protocol] })} ) : null} - +
      - +
      {agentId === "claude-code" ? (
      - + setSmallFastModel(event.target.value)} - placeholder="留空则与主模型相同" + placeholder={t("留空则与主模型相同")} />
      ) : null} @@ -243,7 +246,7 @@ export function AgentDetailPage() { {failure ?

      {failure}

      : null} {applied ? (
      - 已写入配置 + {t("已写入配置")} {applied.restart} {applied.next ?
      {applied.next}
      : null}
      diff --git a/frontend/src/pages/AgentSelectionPage.test.tsx b/frontend/src/pages/AgentSelectionPage.test.tsx index 1986ca58..f79087e8 100644 --- a/frontend/src/pages/AgentSelectionPage.test.tsx +++ b/frontend/src/pages/AgentSelectionPage.test.tsx @@ -42,7 +42,8 @@ function renderPage() { status: { apiVersion: 1, platform: { os: "macos", arch: "arm64", shell: "bash" }, - capabilities: { canInstall: {}, supportedAgentIds: [] }, + runtimes: [], + capabilities: { canInstall: {}, missingRuntime: {}, supportedAgentIds: [] }, agents: Object.fromEntries( CATALOG.map((item) => [ item.id, @@ -55,6 +56,7 @@ function renderPage() { lockedVersion: item.lockedVersion, canInstall: !item.guideOnly, provider: null, + profileId: null, model: null, baseUrl: null, updatedAt: null, diff --git a/frontend/src/pages/AgentSelectionPage.tsx b/frontend/src/pages/AgentSelectionPage.tsx index d9b3d960..a81f2115 100644 --- a/frontend/src/pages/AgentSelectionPage.tsx +++ b/frontend/src/pages/AgentSelectionPage.tsx @@ -4,13 +4,16 @@ import { useNavigate } from "react-router-dom"; import { AgentRow } from "../components/AgentRow"; import { PageScaffold } from "../components/PageScaffold"; +import { RuntimePrompt } from "../components/RuntimePrompt"; +import { useI18n } from "../i18n"; import { splitByRank } from "../state/ranking"; import type { AgentCatalogItem } from "../types/api"; import { useWizard } from "../state/WizardContext"; export function AgentSelectionPage() { const navigate = useNavigate(); - const { state, dispatch } = useWizard(); + const { t } = useI18n(); + const { state, dispatch, refreshStatus } = useWizard(); const [showMore, setShowMore] = useState(false); // Ranked, not grouped by catalog group. Leading with the "auto" group put Kilo // and Aider on the first screen and folded Cursor, OpenClaw and Hermes away. @@ -34,24 +37,24 @@ export function AgentSelectionPage() { return ( navigate("/setup/mode")} primaryDisabled={!state.selectedAgentIds.length || state.statusState === "loading"} - footerNote={state.selectedAgentIds.length ? `已选择 ${state.selectedAgentIds.length} 个 Agent` : "至少选择一个 Agent"} + footerNote={state.selectedAgentIds.length ? t("已选择 {count} 个 Agent", { count: state.selectedAgentIds.length }) : t("至少选择一个 Agent")} bodyClassName="agent-selection-body" > - {state.statusState === "loading" ?
      正在检测本机环境
      : null} + {state.statusState === "loading" ?
      {t("正在检测本机环境")}
      : null} {state.statusError ?
      {state.statusError}
      : null} {state.status ? ( <>
      -

      常用 Agent

      -

      可一键配置的使用锁定版本完成初始化,仅引导的只显示官方步骤。

      +

      {t("常用 Agent")}

      +

      {t("可一键配置的使用锁定版本完成初始化,仅引导的只显示官方步骤。")}

      @@ -61,16 +64,16 @@ export function AgentSelectionPage() {
      {showMore ?
      {renderRows(secondary)}
      : null}
      + + {/* Installing a selected Agent needs its package manager. Offering the + runtime here, before the wizard collects a key and a model, keeps the + activation run from failing on a prerequisite the user cannot fix + from the last step. */} + {state.installMissingAgents ? ( + + ) : null} ) : null} diff --git a/frontend/src/pages/ConfigModePage.tsx b/frontend/src/pages/ConfigModePage.tsx index d3e750ee..1a7d42cc 100644 --- a/frontend/src/pages/ConfigModePage.tsx +++ b/frontend/src/pages/ConfigModePage.tsx @@ -3,41 +3,43 @@ import { useNavigate } from "react-router-dom"; import { ChoiceRow } from "../components/ChoiceRow"; import { PageScaffold } from "../components/PageScaffold"; +import { useI18n } from "../i18n"; import { useWizard } from "../state/WizardContext"; export function ConfigModePage() { const navigate = useNavigate(); + const { t } = useI18n(); const { state, dispatch } = useWizard(); return ( navigate("/setup/agents")} - primaryLabel="继续" + primaryLabel={t("继续")} onPrimary={() => navigate(state.configMode === "existing-account" ? "/setup/review" : "/setup/provider")} primaryDisabled={!state.configMode} - footerNote={state.configMode === "existing-account" ? "Provider 与模型步骤将标记为已跳过" : undefined} + footerNote={state.configMode === "existing-account" ? t("Provider 与模型步骤将标记为已跳过") : undefined} >
      dispatch({ type: "SET_CONFIG_MODE", value: "provider" })} /> dispatch({ type: "SET_CONFIG_MODE", value: "existing-account" })} />
      -
      跳过配置不会删除或覆盖现有 Agent 设置。
      +
      {t("跳过配置不会删除或覆盖现有 Agent 设置。")}
      ); } diff --git a/frontend/src/pages/EnvironmentOverviewPage.test.tsx b/frontend/src/pages/EnvironmentOverviewPage.test.tsx new file mode 100644 index 00000000..ed37f9f4 --- /dev/null +++ b/frontend/src/pages/EnvironmentOverviewPage.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { StatusResponse } from "../types/api"; +import { EnvironmentOverviewPage } from "./EnvironmentOverviewPage"; + +vi.mock("../state/WizardContext", () => ({ + useWizard: () => ({ state: mockState, refreshStatus: vi.fn() }), +})); + +let mockState: { status: StatusResponse | null; statusState: string; statusError: string }; + +function status(): StatusResponse { + const agent = (installed: boolean, profileId: string | null) => ({ + installed, + configured: installed, + guideOnly: false, + config: "/config", + version: installed ? "1.0.0" : null, + lockedVersion: "1.0.0", + canInstall: true, + provider: installed ? "ppio" : null, + profileId, + model: installed ? "model-a" : null, + baseUrl: installed ? "https://api.ppio.com/openai" : null, + updatedAt: null, + detected: null, + }); + return { + apiVersion: 1, + platform: { os: "macos", arch: "arm64", shell: "zsh" }, + runtimes: [], + capabilities: { canInstall: {}, missingRuntime: {}, supportedAgentIds: [] }, + agents: { codex: agent(true, "team"), opencode: agent(false, null) }, + catalog: [ + { id: "opencode", name: "OpenCode", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "1.0.0", protocol: "openai", platforms: ["macos"], platformNote: "", rank: 4 }, + { id: "codex", name: "Codex", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "1.0.0", protocol: "responses", platforms: ["macos"], platformNote: "", rank: 1 }, + ], + groups: [], + providers: { ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" } }, + mirrors: [], + paths: {}, + backups: {}, + profiles: [{ id: "team", label: "团队默认", provider: "ppio", baseUrl: null, model: "model-a", agentIds: ["codex"], activatedAt: null, hasKey: true }], + activeProfile: "team", + environment: null, + environmentError: null, + }; +} + +describe("EnvironmentOverviewPage", () => { + it("shows only installed Agents with their Provider and Profile", () => { + mockState = { status: status(), statusState: "success", statusError: "" }; + render(); + expect(screen.getByText("Codex")).toBeTruthy(); + expect(screen.queryByText("OpenCode")).toBeNull(); + expect(screen.getByText("PPIO")).toBeTruthy(); + expect(screen.getByText("团队默认")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /配置|安装/ })).toBeNull(); + }); + + it("keeps an empty environment informational", () => { + const empty = status(); + empty.agents.codex.installed = false; + mockState = { status: empty, statusState: "success", statusError: "" }; + render(); + expect(screen.getByText("尚未安装任何 Agent")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /开始|新建/ })).toBeNull(); + }); +}); diff --git a/frontend/src/pages/EnvironmentOverviewPage.tsx b/frontend/src/pages/EnvironmentOverviewPage.tsx index 54035ba0..f0a8c0dc 100644 --- a/frontend/src/pages/EnvironmentOverviewPage.tsx +++ b/frontend/src/pages/EnvironmentOverviewPage.tsx @@ -1,76 +1,45 @@ -import { RefreshCw, RotateCcw } from "lucide-react"; -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { PackageOpen, RefreshCw } from "lucide-react"; -import { AgentManageRow, isBehind } from "../components/AgentManageRow"; -import { GuideOnlyRow } from "../components/GuideOnlyRow"; +import { AgentManageRow } from "../components/AgentManageRow"; import { PageScaffold } from "../components/PageScaffold"; -import { splitByRank } from "../state/ranking"; +import { RuntimeSection } from "../components/RuntimeSection"; +import { useI18n } from "../i18n"; import { useWizard } from "../state/WizardContext"; export function EnvironmentOverviewPage() { - const navigate = useNavigate(); - const { state, dispatch, refreshStatus } = useWizard(); - const [showGuideOnly, setShowGuideOnly] = useState(false); + const { t } = useI18n(); + const { state, refreshStatus } = useWizard(); const status = state.status; if (state.statusState === "loading" && !status) { return ( - -
      正在读取环境状态
      + +
      {t("正在读取环境状态")}
      ); } - const catalogById = new Map(status?.catalog.map((item) => [item.id, item]) ?? []); - const managed = (status?.catalog ?? []).filter((item) => item.configMode === "auto"); - const { primary, secondary } = splitByRank(status?.catalog); - - // Either kind of evidence counts as "configured". A per-Agent binding covers - // an Agent set up through "oneagent agent set", which has no environment - // profile; the profile covers a wizard run whose bindings have not been - // re-read yet. Requiring only bindings would report a freshly finished wizard - // as "nothing configured". - const configuredCount = managed.filter((item) => status?.agents[item.id]?.provider).length; - const hasAnyConfiguration = Boolean(status?.environment) || configuredCount > 0; - - if (!status || !hasAnyConfiguration) { + if (!status) { return ( - navigate("/setup/agents")} - > +
      - - 尚未配置任何 Agent - {status?.environmentError || "完成一次配置后,这里会列出每个 Agent 指向的 Provider 与模型,并可随时单独调整。"} + + {t("无法读取环境状态")} + {state.statusError || t("请刷新后重试。")}
      ); } - // Behind means older than the locked version, not merely different: a user - // who upgraded an Agent themselves is ahead, and counting that as an update - // would nag them to downgrade. - const behind = managed.filter((item) => { - const agent = status.agents[item.id]; - return Boolean( - agent?.installed && agent.version && agent.lockedVersion && isBehind(agent.version, agent.lockedVersion), - ); - }); - const unconfigured = managed.filter((item) => status.agents[item.id]?.installed && !status.agents[item.id]?.provider); + const installed = [...status.catalog] + .sort((first, second) => first.rank - second.rank) + .filter((item) => status.agents[item.id]?.installed); + const profiles = new Map(status.profiles.map((profile) => [profile.id, profile.label])); return ( { - dispatch({ type: "RESET_SETUP" }); - navigate("/setup/agents"); - }} + title={t("环境总览")} + description={t("本机已安装 Agent 及其当前 Provider、Profile 与模型。")} secondaryAction={ } > -
      -
      - {primary.map((item) => { - const agent = status.agents[item.id]; - if (item.configMode !== "auto") { - return ; - } - if (!agent) return null; - return ( - + + {installed.length ? ( +
      +
      +

      {t("已安装 Agent")}

      {t("共 {count} 个", { count: installed.length })}

      +
      +
      + {installed.map((item) => { + const agent = status.agents[item.id]; + if (!agent) return null; + return navigate(`/agents/${item.id}`)} - /> - ); - })} -
      -
      - -
      - - {showGuideOnly ? ( -
      - {secondary.map((item) => { - const agent = status.agents[item.id]; - if (item.configMode !== "auto") { - return ; - } - if (!agent) return null; - return ( - navigate(`/agents/${item.id}`)} - /> - ); + profileName={agent.profileId ? profiles.get(agent.profileId) || agent.profileId : ""} + />; })}
      - ) : null} -
      - +
      + ) : ( +
      + + {t("尚未安装任何 Agent")} + {t("在配置模板中创建 Profile 并应用后,已安装的 Agent 会显示在这里。")} +
      + )}
      ); } diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx deleted file mode 100644 index de1da036..00000000 --- a/frontend/src/pages/LandingPage.tsx +++ /dev/null @@ -1,480 +0,0 @@ -import { useState } from "react"; -import { - ArrowRight, - ArrowUpRight, - Bot, - Check, - ChevronDown, - Command, - GitBranch, - Layers3, - Menu, - Network, - ShieldCheck, - Sparkles, - TerminalSquare, - X, - Zap, -} from "lucide-react"; -import { Link } from "react-router-dom"; - -type DemoTab = "overview" | "routing" | "profiles"; - -const demoTabs: Array<{ id: DemoTab; label: string }> = [ - { id: "overview", label: "Overview" }, - { id: "routing", label: "Routing" }, - { id: "profiles", label: "Profiles" }, -]; - -const agents = [ - { name: "Codex", detail: "Responses API", tone: "coral" }, - { name: "Claude Code", detail: "Anthropic", tone: "mint" }, - { name: "OpenCode", detail: "OpenAI compatible", tone: "blue" }, - { name: "Aider", detail: "Terminal workflow", tone: "gold" }, -]; - -const faqs = [ - { - question: "Does OneAgent replace my coding agents?", - answer: - "No. OneAgent sits beside them as a small control plane. You keep the tools you already like and use OneAgent to manage the provider, model, and profile each one should use.", - }, - { - question: "Where does my configuration live?", - answer: - "Locally by default. OneAgent keeps the working configuration on your machine and gives you an explicit view of what each agent is connected to before you apply a change.", - }, - { - question: "Can different agents use different models?", - answer: - "Yes. Each agent gets its own lane, so a terminal agent can use one provider while an IDE extension or review agent uses another. Switch the binding without editing scattered dotfiles.", - }, -]; - -function scrollToSection(id: string) { - document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); -} - -function BrandMark() { - return ( -
      @@ -86,12 +191,13 @@ const defaultTarget = channel.targets.find((target) => target.status === "availa })} +)} diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index 736c39a2..f29178b4 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -1,38 +1,69 @@ --- import BrandMark from "./BrandMark.astro"; +import { bestLocaleFor, localeFromPath, localePath, switchesLanguage } from "../i18n"; +import { useTranslations } from "../i18n/ui"; +import { releasesPageUrl } from "../lib/downloads"; + const year = new Date().getUTCFullYear(); +const locale = localeFromPath(Astro.url.pathname); +const t = useTranslations(locale); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +/* Column links, grouped as rendered. A `path` is resolved per locale and gets a + hint when the target has no translation; an `href` is an absolute artifact URL + that is the same in every locale. */ +type FooterLink = { path: string; label: string } | { href: string; label: string }; +const columns: { heading: string; links: FooterLink[] }[] = [ + { + heading: t("footer.start"), + links: [ + { path: "downloads/", label: t("footer.downloadCenter") }, + { path: "quickstart/", label: t("nav.quickstart") }, + { path: "changelog/", label: t("nav.changelog") }, + ], + }, + { + heading: t("footer.capability"), + links: [ + { path: "explore/", label: t("nav.explorer") }, + { path: "agents/", label: t("footer.agentCatalog") }, + { path: "providers/", label: t("footer.providerCatalog") }, + ], + }, + { + heading: t("footer.trust"), + links: [ + { path: "support/", label: t("footer.supportFeedback") }, + // A published artifact rather than a page: same URL in every locale, so it + // is exempt from the locale resolution and the hint. + { href: releasesPageUrl, label: t("footer.releaseIndex") }, + ], + }, +]; --- diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 1d5a5f31..3d5b162c 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -1,38 +1,56 @@ --- import BrandMark from "./BrandMark.astro"; +import ThemeToggle from "./ThemeToggle.astro"; +import LocaleSwitch from "./LocaleSwitch.astro"; +import { bestLocaleFor, localeFromPath, localePath, routeWithoutLocale, switchesLanguage } from "../i18n"; +import { useTranslations } from "../i18n/ui"; const pathname = Astro.url.pathname; -const basePath = import.meta.env.BASE_URL; -const withBase = (path: string) => `${basePath}${path.replace(/^\/+/, "")}`; +const locale = localeFromPath(pathname); +const t = useTranslations(locale); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +/* The security and enterprise pages stay published and linked from the footer, + the release index and the demo's preview-gate result — they are just not + worth a top-level nav slot. */ const nav = [ - { path: "downloads/", label: "下载" }, - { path: "quickstart/", label: "快速开始" }, - { path: "agents/", label: "Agent" }, - { path: "providers/", label: "Provider" }, - { path: "security/", label: "安全" }, + { path: "downloads/", label: t("nav.downloads") }, + { path: "quickstart/", label: t("nav.quickstart") }, + { path: "explore/", label: t("nav.explorer") }, ]; -const active = (path: string) => pathname.includes(`/${path}`); +// Compared on the locale-stripped route so /en/agents/ marks the same item as +// /agents/ rather than matching on a substring of the full path. +const route = routeWithoutLocale(pathname); +const active = (path: string) => + route === path || + (path === "explore/" && (route === "agents/" || route.startsWith("agents/") || route === "providers/" || route.startsWith("providers/"))); ---